diff --git a/.gitignore b/.gitignore
index dfcfd56..ace6b20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -348,3 +348,7 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
+
+## Test Coverage Reports (generated artifacts)
+**/TestResults/
+**/CoverageReport/
diff --git a/Models/ListQueryParams.cs b/src/Models/ListQueryParams.cs
similarity index 100%
rename from Models/ListQueryParams.cs
rename to src/Models/ListQueryParams.cs
diff --git a/Models/StorageData.cs b/src/Models/StorageData.cs
similarity index 100%
rename from Models/StorageData.cs
rename to src/Models/StorageData.cs
diff --git a/Program.cs b/src/Program.cs
similarity index 100%
rename from Program.cs
rename to src/Program.cs
diff --git a/Services/AppSettings.cs b/src/Services/AppSettings.cs
similarity index 100%
rename from Services/AppSettings.cs
rename to src/Services/AppSettings.cs
diff --git a/Services/AuthenticationService.cs b/src/Services/AuthenticationService.cs
similarity index 100%
rename from Services/AuthenticationService.cs
rename to src/Services/AuthenticationService.cs
diff --git a/Services/DataStorageService.cs b/src/Services/DataStorageService.cs
similarity index 100%
rename from Services/DataStorageService.cs
rename to src/Services/DataStorageService.cs
diff --git a/Services/DataSync/DataSyncService.Constituent.cs b/src/Services/DataSync/DataSyncService.Constituent.cs
similarity index 100%
rename from Services/DataSync/DataSyncService.Constituent.cs
rename to src/Services/DataSync/DataSyncService.Constituent.cs
diff --git a/Services/DataSync/DataSyncService.cs b/src/Services/DataSync/DataSyncService.cs
similarity index 100%
rename from Services/DataSync/DataSyncService.cs
rename to src/Services/DataSync/DataSyncService.cs
diff --git a/Services/DataSync/IDataSyncService.cs b/src/Services/DataSync/IDataSyncService.cs
similarity index 100%
rename from Services/DataSync/IDataSyncService.cs
rename to src/Services/DataSync/IDataSyncService.cs
diff --git a/Services/IAuthenticationService.cs b/src/Services/IAuthenticationService.cs
similarity index 100%
rename from Services/IAuthenticationService.cs
rename to src/Services/IAuthenticationService.cs
diff --git a/Services/IDataStorageService.cs b/src/Services/IDataStorageService.cs
similarity index 100%
rename from Services/IDataStorageService.cs
rename to src/Services/IDataStorageService.cs
diff --git a/Services/SkyApi/ConstituentsService.cs b/src/Services/SkyApi/ConstituentsService.cs
similarity index 100%
rename from Services/SkyApi/ConstituentsService.cs
rename to src/Services/SkyApi/ConstituentsService.cs
diff --git a/Services/SkyApi/IConstituentsService.cs b/src/Services/SkyApi/IConstituentsService.cs
similarity index 100%
rename from Services/SkyApi/IConstituentsService.cs
rename to src/Services/SkyApi/IConstituentsService.cs
diff --git a/SyncApp.cs b/src/SyncApp.cs
similarity index 100%
rename from SyncApp.cs
rename to src/SyncApp.cs
diff --git a/appsettings.json b/src/appsettings.json
similarity index 100%
rename from appsettings.json
rename to src/appsettings.json
diff --git a/skyapi-headless-data-sync.csproj b/src/skyapi-headless-data-sync.csproj
similarity index 93%
rename from skyapi-headless-data-sync.csproj
rename to src/skyapi-headless-data-sync.csproj
index 72d1f40..362f6e2 100644
--- a/skyapi-headless-data-sync.csproj
+++ b/src/skyapi-headless-data-sync.csproj
@@ -24,4 +24,8 @@
+
+
+
+
diff --git a/skyapi-headless-data-sync.sln b/src/skyapi-headless-data-sync.sln
similarity index 100%
rename from skyapi-headless-data-sync.sln
rename to src/skyapi-headless-data-sync.sln
diff --git a/tests/Models/AppSettingsTests.cs b/tests/Models/AppSettingsTests.cs
new file mode 100644
index 0000000..5e38fb3
--- /dev/null
+++ b/tests/Models/AppSettingsTests.cs
@@ -0,0 +1,136 @@
+using Blackbaud.HeadlessDataSync.Services;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Models
+{
+ public class AppSettingsTests
+ {
+ [Fact]
+ public void Properties_CanBeSetAndRetrieved()
+ {
+ var authBaseUri = "https://oauth2.sky.blackbaud.com/";
+ var authClientId = "test-client-id";
+ var authClientSecret = "test-client-secret";
+ var skyApiSubscriptionKey = "test-subscription-key";
+ var skyApiBaseUri = "https://api.sky.blackbaud.com/";
+
+ var appSettings = new AppSettings
+ {
+ AuthBaseUri = authBaseUri,
+ AuthClientId = authClientId,
+ AuthClientSecret = authClientSecret,
+ SkyApiSubscriptionKey = skyApiSubscriptionKey,
+ SkyApiBaseUri = skyApiBaseUri
+ };
+
+ Assert.Equal(authBaseUri, appSettings.AuthBaseUri);
+ Assert.Equal(authClientId, appSettings.AuthClientId);
+ Assert.Equal(authClientSecret, appSettings.AuthClientSecret);
+ Assert.Equal(skyApiSubscriptionKey, appSettings.SkyApiSubscriptionKey);
+ Assert.Equal(skyApiBaseUri, appSettings.SkyApiBaseUri);
+ }
+
+ [Fact]
+ public void DefaultConstructor_InitializesWithNullValues()
+ {
+ var appSettings = new AppSettings();
+
+ Assert.Null(appSettings.AuthBaseUri);
+ Assert.Null(appSettings.AuthClientId);
+ Assert.Null(appSettings.AuthClientSecret);
+ Assert.Null(appSettings.SkyApiSubscriptionKey);
+ Assert.Null(appSettings.SkyApiBaseUri);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("https://oauth2.sky.blackbaud.com/")]
+ public void AuthBaseUri_CanBeSetToVariousValues(string value)
+ {
+ var appSettings = new AppSettings
+ {
+ AuthBaseUri = value
+ };
+
+ Assert.Equal(value, appSettings.AuthBaseUri);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("client-id-123")]
+ public void AuthClientId_CanBeSetToVariousValues(string value)
+ {
+ var appSettings = new AppSettings
+ {
+ AuthClientId = value
+ };
+
+ Assert.Equal(value, appSettings.AuthClientId);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("client-secret-456")]
+ public void AuthClientSecret_CanBeSetToVariousValues(string value)
+ {
+ var appSettings = new AppSettings
+ {
+ AuthClientSecret = value
+ };
+
+ Assert.Equal(value, appSettings.AuthClientSecret);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("subscription-key-789")]
+ public void SkyApiSubscriptionKey_CanBeSetToVariousValues(string value)
+ {
+ var appSettings = new AppSettings
+ {
+ SkyApiSubscriptionKey = value
+ };
+
+ Assert.Equal(value, appSettings.SkyApiSubscriptionKey);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("https://api.sky.blackbaud.com/")]
+ public void SkyApiBaseUri_CanBeSetToVariousValues(string value)
+ {
+ var appSettings = new AppSettings
+ {
+ SkyApiBaseUri = value
+ };
+
+ Assert.Equal(value, appSettings.SkyApiBaseUri);
+ }
+
+ [Fact]
+ public void AllProperties_CanBeSetIndependently()
+ {
+ var appSettings = new AppSettings();
+
+ appSettings.AuthBaseUri = "auth-base";
+ Assert.Equal("auth-base", appSettings.AuthBaseUri);
+
+ appSettings.AuthClientId = "client-id";
+ Assert.Equal("client-id", appSettings.AuthClientId);
+
+ appSettings.AuthClientSecret = "client-secret";
+ Assert.Equal("client-secret", appSettings.AuthClientSecret);
+
+ appSettings.SkyApiSubscriptionKey = "subscription-key";
+ Assert.Equal("subscription-key", appSettings.SkyApiSubscriptionKey);
+
+ appSettings.SkyApiBaseUri = "api-base";
+ Assert.Equal("api-base", appSettings.SkyApiBaseUri);
+ }
+ }
+}
diff --git a/tests/Models/ListQueryParamsTests.cs b/tests/Models/ListQueryParamsTests.cs
new file mode 100644
index 0000000..c8104e8
--- /dev/null
+++ b/tests/Models/ListQueryParamsTests.cs
@@ -0,0 +1,141 @@
+using Blackbaud.HeadlessDataSync.Models;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Models
+{
+ public class ListQueryParamsTests
+ {
+ [Fact]
+ public void IsEmpty_WithBothPropertiesNull_ReturnsTrue()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = null,
+ SortToken = null
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsEmpty_WithBothPropertiesEmpty_ReturnsTrue()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = string.Empty,
+ SortToken = string.Empty
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsEmpty_WithLastModifiedOnly_ReturnsFalse()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = null
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsEmpty_WithSortTokenOnly_ReturnsFalse()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = null,
+ SortToken = "test-sort-token"
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsEmpty_WithBothPropertiesSet_ReturnsFalse()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-sort-token"
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.False(result);
+ }
+
+ [Theory]
+ [InlineData("", "")]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData(null, "")]
+ public void IsEmpty_WithVariousEmptyValues_ReturnsTrue(string lastModified, string sortToken)
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = lastModified,
+ SortToken = sortToken
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData("2023-01-01T00:00:00Z", "")]
+ [InlineData("2023-01-01T00:00:00Z", null)]
+ [InlineData("", "token123")]
+ [InlineData(null, "token123")]
+ [InlineData("2023-01-01T00:00:00Z", "token123")]
+ public void IsEmpty_WithAtLeastOneNonEmptyValue_ReturnsFalse(string lastModified, string sortToken)
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = lastModified,
+ SortToken = sortToken
+ };
+
+ var result = queryParams.IsEmpty();
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void Properties_CanBeSetAndRetrieved()
+ {
+ var lastModified = "2023-01-01T00:00:00Z";
+ var sortToken = "test-sort-token";
+
+ var queryParams = new ListQueryParams
+ {
+ LastModified = lastModified,
+ SortToken = sortToken
+ };
+
+ Assert.Equal(lastModified, queryParams.LastModified);
+ Assert.Equal(sortToken, queryParams.SortToken);
+ }
+
+ [Fact]
+ public void DefaultConstructor_InitializesWithNullValues()
+ {
+ var queryParams = new ListQueryParams();
+
+ Assert.Null(queryParams.LastModified);
+ Assert.Null(queryParams.SortToken);
+ Assert.True(queryParams.IsEmpty());
+ }
+ }
+}
diff --git a/tests/Models/StorageDataTests.cs b/tests/Models/StorageDataTests.cs
new file mode 100644
index 0000000..83c4d5d
--- /dev/null
+++ b/tests/Models/StorageDataTests.cs
@@ -0,0 +1,134 @@
+using Blackbaud.HeadlessDataSync.Models;
+using System;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Models
+{
+ public class StorageDataTests
+ {
+ [Fact]
+ public void Properties_CanBeSetAndRetrieved()
+ {
+ var lastSyncDate = DateTimeOffset.Now;
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-token"
+ };
+ var accessToken = "test-access-token";
+ var refreshToken = "test-refresh-token";
+
+ var storageData = new StorageData
+ {
+ LastSyncDate = lastSyncDate,
+ ConstituentQueryParams = queryParams,
+ AccessToken = accessToken,
+ RefreshToken = refreshToken
+ };
+
+ Assert.Equal(lastSyncDate, storageData.LastSyncDate);
+ Assert.Equal(queryParams, storageData.ConstituentQueryParams);
+ Assert.Equal(accessToken, storageData.AccessToken);
+ Assert.Equal(refreshToken, storageData.RefreshToken);
+ }
+
+ [Fact]
+ public void DefaultConstructor_InitializesWithDefaultValues()
+ {
+ var storageData = new StorageData();
+
+ Assert.Equal(DateTimeOffset.MinValue, storageData.LastSyncDate);
+ Assert.Null(storageData.ConstituentQueryParams);
+ Assert.Null(storageData.AccessToken);
+ Assert.Null(storageData.RefreshToken);
+ }
+
+ [Fact]
+ public void LastSyncDate_CanBeSetToSpecificDate()
+ {
+ var specificDate = new DateTimeOffset(2023, 1, 1, 12, 0, 0, TimeSpan.Zero);
+ var storageData = new StorageData
+ {
+ LastSyncDate = specificDate
+ };
+
+ Assert.Equal(specificDate, storageData.LastSyncDate);
+ }
+
+ [Fact]
+ public void ConstituentQueryParams_CanBeSetToNull()
+ {
+ var storageData = new StorageData
+ {
+ ConstituentQueryParams = null
+ };
+
+ Assert.Null(storageData.ConstituentQueryParams);
+ }
+
+ [Fact]
+ public void ConstituentQueryParams_CanBeSetToValidObject()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-token"
+ };
+
+ var storageData = new StorageData
+ {
+ ConstituentQueryParams = queryParams
+ };
+
+ Assert.NotNull(storageData.ConstituentQueryParams);
+ Assert.Equal("2023-01-01T00:00:00Z", storageData.ConstituentQueryParams.LastModified);
+ Assert.Equal("test-token", storageData.ConstituentQueryParams.SortToken);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("valid-access-token")]
+ public void AccessToken_CanBeSetToVariousValues(string token)
+ {
+ var storageData = new StorageData
+ {
+ AccessToken = token
+ };
+
+ Assert.Equal(token, storageData.AccessToken);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("valid-refresh-token")]
+ public void RefreshToken_CanBeSetToVariousValues(string token)
+ {
+ var storageData = new StorageData
+ {
+ RefreshToken = token
+ };
+
+ Assert.Equal(token, storageData.RefreshToken);
+ }
+
+ [Fact]
+ public void AllProperties_CanBeSetIndependently()
+ {
+ var storageData = new StorageData();
+
+ storageData.LastSyncDate = DateTimeOffset.Now;
+ Assert.NotEqual(DateTimeOffset.MinValue, storageData.LastSyncDate);
+
+ storageData.ConstituentQueryParams = new ListQueryParams { LastModified = "test" };
+ Assert.NotNull(storageData.ConstituentQueryParams);
+
+ storageData.AccessToken = "access";
+ Assert.Equal("access", storageData.AccessToken);
+
+ storageData.RefreshToken = "refresh";
+ Assert.Equal("refresh", storageData.RefreshToken);
+ }
+ }
+}
diff --git a/tests/Services/AuthenticationServiceTests.cs b/tests/Services/AuthenticationServiceTests.cs
new file mode 100644
index 0000000..8a35492
--- /dev/null
+++ b/tests/Services/AuthenticationServiceTests.cs
@@ -0,0 +1,102 @@
+using Blackbaud.HeadlessDataSync.Services;
+using Microsoft.Extensions.Options;
+using Moq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Services
+{
+ public class AuthenticationServiceTests
+ {
+ private readonly Mock> _mockAppSettings;
+ private readonly Mock _mockDataStorageService;
+ private readonly AppSettings _appSettings;
+
+ public AuthenticationServiceTests()
+ {
+ _appSettings = new AppSettings
+ {
+ AuthBaseUri = "https://oauth2.sky.blackbaud.com/",
+ AuthClientId = "test-client-id",
+ AuthClientSecret = "test-client-secret"
+ };
+
+ _mockAppSettings = new Mock>();
+ _mockAppSettings.Setup(x => x.Value).Returns(_appSettings);
+
+ _mockDataStorageService = new Mock();
+ }
+
+ [Fact]
+ public void Constructor_WithValidDependencies_CreatesInstance()
+ {
+ var authService = new AuthenticationService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object);
+
+ Assert.NotNull(authService);
+ }
+
+ [Fact]
+ public void RefreshAccessToken_WithValidRefreshToken_CallsDataStorageService()
+ {
+ var refreshToken = "valid-refresh-token";
+
+ _mockDataStorageService.Setup(x => x.GetRefreshToken()).Returns(refreshToken);
+ _mockDataStorageService.Setup(x => x.SetTokensFromResponse(It.IsAny()));
+
+ var authService = new AuthenticationService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object);
+
+ var result = authService.RefreshAccessToken();
+
+ Assert.NotNull(result);
+ _mockDataStorageService.Verify(x => x.GetRefreshToken(), Times.Once);
+ _mockDataStorageService.Verify(x => x.SetTokensFromResponse(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void RefreshAccessToken_WithNullRefreshToken_StillMakesRequest()
+ {
+ _mockDataStorageService.Setup(x => x.GetRefreshToken()).Returns((string)null);
+ _mockDataStorageService.Setup(x => x.SetTokensFromResponse(It.IsAny()));
+
+ var authService = new AuthenticationService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object);
+
+ var result = authService.RefreshAccessToken();
+
+ Assert.NotNull(result);
+ _mockDataStorageService.Verify(x => x.GetRefreshToken(), Times.Once);
+ _mockDataStorageService.Verify(x => x.SetTokensFromResponse(It.IsAny()), Times.Once);
+ }
+
+ [Theory]
+ [InlineData("test", "dGVzdA==")]
+ [InlineData("client:secret", "Y2xpZW50OnNlY3JldA==")]
+ [InlineData("", "")]
+ public void Base64Encode_EncodesCorrectly(string input, string expected)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(input);
+ var result = Convert.ToBase64String(bytes);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void AppSettings_AreAccessedCorrectly()
+ {
+ var authService = new AuthenticationService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object);
+
+ authService.RefreshAccessToken();
+
+ _mockAppSettings.Verify(x => x.Value, Times.AtLeastOnce);
+ }
+ }
+}
diff --git a/tests/Services/DataStorageServiceTests.cs b/tests/Services/DataStorageServiceTests.cs
new file mode 100644
index 0000000..f040660
--- /dev/null
+++ b/tests/Services/DataStorageServiceTests.cs
@@ -0,0 +1,223 @@
+using Blackbaud.HeadlessDataSync.Models;
+using Blackbaud.HeadlessDataSync.Services;
+using Microsoft.AspNetCore.DataProtection;
+using Moq;
+using Newtonsoft.Json;
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Services
+{
+ public class DataStorageServiceTests : IDisposable
+ {
+ private readonly Mock _mockDataProtectionProvider;
+ private readonly Mock _mockDataProtector;
+ private readonly string _testStorageFile;
+
+ public DataStorageServiceTests()
+ {
+ _mockDataProtectionProvider = new Mock();
+ _mockDataProtector = new Mock();
+ _mockDataProtectionProvider.Setup(x => x.CreateProtector(It.IsAny())).Returns(_mockDataProtector.Object);
+
+ _testStorageFile = Path.Combine(Path.GetTempPath(), "test_headlessdatasync_storage.json");
+ if (File.Exists(_testStorageFile))
+ {
+ File.Delete(_testStorageFile);
+ }
+
+ var currentDirStorage = "headlessdatasync_storage.json";
+ if (File.Exists(currentDirStorage))
+ {
+ File.Delete(currentDirStorage);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_testStorageFile))
+ {
+ File.Delete(_testStorageFile);
+ }
+
+ var currentDirStorage = "headlessdatasync_storage.json";
+ if (File.Exists(currentDirStorage))
+ {
+ File.Delete(currentDirStorage);
+ }
+ }
+
+ [Fact]
+ public void Constructor_WithValidDataProtectionProvider_CreatesInstance()
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ Assert.NotNull(service);
+ _mockDataProtectionProvider.Verify(x => x.CreateProtector(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void SetAndGetAccessToken_WithValidToken_StoresAndRetrieves()
+ {
+ var originalToken = "test-access-token";
+
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetAccessToken(originalToken);
+ var retrievedToken = service.GetAccessToken();
+
+ Assert.NotNull(service);
+ }
+
+ [Fact]
+ public void SetAndGetRefreshToken_WithValidToken_StoresAndRetrieves()
+ {
+ var originalToken = "test-refresh-token";
+
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetRefreshToken(originalToken);
+ var retrievedToken = service.GetRefreshToken();
+
+ Assert.NotNull(service);
+ }
+
+ [Fact]
+ public void SetAndGetLastSyncDate_WithValidDate_StoresAndRetrieves()
+ {
+ var testDate = DateTimeOffset.Now;
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetLastSyncDate(testDate);
+ var retrievedDate = service.GetLastSyncDate();
+
+ Assert.Equal(testDate, retrievedDate);
+ }
+
+ [Fact]
+ public void SetAndGetConstituentQueryParams_WithValidParams_StoresAndRetrieves()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-sort-token"
+ };
+
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetConstituentQueryParams(queryParams);
+ var retrievedParams = service.GetConstituentQueryParams();
+
+ Assert.NotNull(retrievedParams);
+ Assert.Equal(queryParams.LastModified, retrievedParams.LastModified);
+ Assert.Equal(queryParams.SortToken, retrievedParams.SortToken);
+ }
+
+ [Fact]
+ public void ClearTokens_RemovesAllTokens()
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetAccessToken("access-token");
+ service.SetRefreshToken("refresh-token");
+
+ service.ClearTokens();
+
+ var accessToken = service.GetAccessToken();
+ var refreshToken = service.GetRefreshToken();
+
+ Assert.Null(accessToken);
+ Assert.Null(refreshToken);
+ }
+
+ [Fact]
+ public void SetTokensFromResponse_WithSuccessfulResponse_ExtractsAndStoresTokens()
+ {
+ var responseContent = new
+ {
+ access_token = "new-access-token",
+ refresh_token = "new-refresh-token"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonConvert.SerializeObject(responseContent))
+ };
+
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetTokensFromResponse(response);
+
+ Assert.NotNull(service);
+ Assert.True(response.IsSuccessStatusCode);
+ }
+
+ [Fact]
+ public void SetTokensFromResponse_WithFailedResponse_DoesNotStoreTokens()
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.Unauthorized)
+ {
+ Content = new StringContent("{\"error\":\"invalid_grant\"}")
+ };
+
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetTokensFromResponse(response);
+
+ var accessToken = service.GetAccessToken();
+ var refreshToken = service.GetRefreshToken();
+
+ Assert.Null(accessToken);
+ Assert.Null(refreshToken);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void SetAccessToken_WithEmptyOrNullToken_StoresNull(string token)
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetAccessToken(token);
+ var retrievedToken = service.GetAccessToken();
+
+ Assert.Null(retrievedToken);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void SetRefreshToken_WithEmptyOrNullToken_StoresNull(string token)
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ service.SetRefreshToken(token);
+ var retrievedToken = service.GetRefreshToken();
+
+ Assert.Null(retrievedToken);
+ }
+
+ [Fact]
+ public void GetAccessToken_WithNullStoredToken_ReturnsNull()
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ var retrievedToken = service.GetAccessToken();
+
+ Assert.Null(retrievedToken);
+ }
+
+ [Fact]
+ public void GetRefreshToken_WithNullStoredToken_ReturnsNull()
+ {
+ var service = new DataStorageService(_mockDataProtectionProvider.Object);
+
+ var retrievedToken = service.GetRefreshToken();
+
+ Assert.Null(retrievedToken);
+ }
+ }
+}
diff --git a/tests/Services/DataSync/DataSyncServiceTests.cs b/tests/Services/DataSync/DataSyncServiceTests.cs
new file mode 100644
index 0000000..a3b7239
--- /dev/null
+++ b/tests/Services/DataSync/DataSyncServiceTests.cs
@@ -0,0 +1,284 @@
+using Blackbaud.HeadlessDataSync.Models;
+using Blackbaud.HeadlessDataSync.Services;
+using Blackbaud.HeadlessDataSync.Services.DataSync;
+using Blackbaud.HeadlessDataSync.Services.SkyApi;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Newtonsoft.Json.Linq;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Services.DataSync
+{
+ public class DataSyncServiceTests
+ {
+ private readonly Mock> _mockLogger;
+ private readonly Mock _mockDataStorageService;
+ private readonly Mock _mockConstituentsService;
+
+ public DataSyncServiceTests()
+ {
+ _mockLogger = new Mock>();
+ _mockDataStorageService = new Mock();
+ _mockConstituentsService = new Mock();
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithSuccessfulResponse_ReturnsTrue()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-token"
+ };
+
+ var responseContent = new JObject
+ {
+ ["count"] = 5,
+ ["value"] = new JArray(),
+ ["next_link"] = "https://api.sky.blackbaud.com/constituent/v1/constituents?sort_token=next"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseContent.ToString())
+ };
+
+ var nextLinkParams = new ListQueryParams { SortToken = "next" };
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Returns(response);
+ _mockConstituentsService.Setup(x => x.CreateQueryParamsFromNextLinkUri(It.IsAny())).Returns(nextLinkParams);
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ _mockDataStorageService.Verify(x => x.SetLastSyncDate(It.IsAny()), Times.Once);
+ _mockDataStorageService.Verify(x => x.SetConstituentQueryParams(nextLinkParams), Times.Once);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithNullQueryParams_CreatesNewParams()
+ {
+ var responseContent = new JObject
+ {
+ ["count"] = 0,
+ ["value"] = new JArray(),
+ ["next_link"] = "https://api.sky.blackbaud.com/constituent/v1/constituents?sort_token=next"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseContent.ToString())
+ };
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns((ListQueryParams)null);
+ _mockConstituentsService.Setup(x => x.GetConstituents(It.IsAny())).Returns(response);
+ _mockConstituentsService.Setup(x => x.CreateQueryParamsFromNextLinkUri(It.IsAny())).Returns(new ListQueryParams());
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ _mockConstituentsService.Verify(x => x.GetConstituents(It.Is(p => !string.IsNullOrEmpty(p.LastModified))), Times.Once);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithEmptyQueryParams_CreatesNewParams()
+ {
+ var emptyParams = new ListQueryParams();
+ var responseContent = new JObject
+ {
+ ["count"] = 0,
+ ["value"] = new JArray(),
+ ["next_link"] = "https://api.sky.blackbaud.com/constituent/v1/constituents"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseContent.ToString())
+ };
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(emptyParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(It.IsAny())).Returns(response);
+ _mockConstituentsService.Setup(x => x.CreateQueryParamsFromNextLinkUri(It.IsAny())).Returns(new ListQueryParams());
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ _mockConstituentsService.Verify(x => x.GetConstituents(It.Is(p => !string.IsNullOrEmpty(p.LastModified))), Times.Once);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithUnsuccessfulResponse_ReturnsFalse()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Returns(response);
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithSocketException_ReturnsTrue()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var socketException = new SocketException();
+ var httpException = new HttpRequestException("Network error", socketException);
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Throws(httpException);
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithHttpRequestException_ReturnsTrue()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var innerHttpException = new HttpRequestException("Network timeout");
+ var outerException = new Exception("Outer exception", innerHttpException);
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Throws(outerException);
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithOtherException_ReturnsFalse()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var exception = new InvalidOperationException("Unexpected error");
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Throws(exception);
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithMultipleConstituents_LogsCorrectCount()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var responseContent = new JObject
+ {
+ ["count"] = 10,
+ ["value"] = new JArray(),
+ ["next_link"] = "https://api.sky.blackbaud.com/constituent/v1/constituents?sort_token=next"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseContent.ToString())
+ };
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Returns(response);
+ _mockConstituentsService.Setup(x => x.CreateQueryParamsFromNextLinkUri(It.IsAny())).Returns(new ListQueryParams());
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ _mockLogger.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("10 constituents modified")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task SyncConstituentDataAsync_WithZeroConstituents_DoesNotLogUpdate()
+ {
+ var queryParams = new ListQueryParams { LastModified = "2023-01-01T00:00:00Z" };
+ var responseContent = new JObject
+ {
+ ["count"] = 0,
+ ["value"] = new JArray(),
+ ["next_link"] = "https://api.sky.blackbaud.com/constituent/v1/constituents?sort_token=next"
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseContent.ToString())
+ };
+
+ _mockDataStorageService.Setup(x => x.GetConstituentQueryParams()).Returns(queryParams);
+ _mockConstituentsService.Setup(x => x.GetConstituents(queryParams)).Returns(response);
+ _mockConstituentsService.Setup(x => x.CreateQueryParamsFromNextLinkUri(It.IsAny())).Returns(new ListQueryParams());
+
+ var service = new DataSyncService(
+ _mockLogger.Object,
+ _mockDataStorageService.Object,
+ _mockConstituentsService.Object);
+
+ var result = await service.SyncConstituentDataAsync();
+
+ Assert.True(result);
+ _mockLogger.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Updating")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Never);
+ }
+ }
+}
diff --git a/tests/Services/SkyApi/ConstituentsServiceTests.cs b/tests/Services/SkyApi/ConstituentsServiceTests.cs
new file mode 100644
index 0000000..83d94c4
--- /dev/null
+++ b/tests/Services/SkyApi/ConstituentsServiceTests.cs
@@ -0,0 +1,165 @@
+using Blackbaud.HeadlessDataSync.Models;
+using Blackbaud.HeadlessDataSync.Services;
+using Blackbaud.HeadlessDataSync.Services.SkyApi;
+using Microsoft.Extensions.Options;
+using Moq;
+using System;
+using System.Net;
+using System.Net.Http;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests.Services.SkyApi
+{
+ public class ConstituentsServiceTests
+ {
+ private readonly Mock> _mockAppSettings;
+ private readonly Mock _mockDataStorageService;
+ private readonly Mock _mockAuthService;
+ private readonly AppSettings _appSettings;
+
+ public ConstituentsServiceTests()
+ {
+ _appSettings = new AppSettings
+ {
+ SkyApiBaseUri = "https://api.sky.blackbaud.com/",
+ SkyApiSubscriptionKey = "test-subscription-key"
+ };
+
+ _mockAppSettings = new Mock>();
+ _mockAppSettings.Setup(x => x.Value).Returns(_appSettings);
+
+ _mockDataStorageService = new Mock();
+ _mockAuthService = new Mock();
+ }
+
+ [Fact]
+ public void Constructor_WithValidDependencies_CreatesInstance()
+ {
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ Assert.NotNull(service);
+ }
+
+ [Fact]
+ public void GetConstituents_WithValidQueryParams_CallsDataStorageService()
+ {
+ var queryParams = new ListQueryParams
+ {
+ LastModified = "2023-01-01T00:00:00Z",
+ SortToken = "test-token"
+ };
+
+ _mockDataStorageService.Setup(x => x.GetAccessToken()).Returns("valid-access-token");
+ _mockAuthService.Setup(x => x.RefreshAccessToken()).Returns(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.GetConstituents(queryParams);
+
+ Assert.NotNull(result);
+ _mockDataStorageService.Verify(x => x.GetAccessToken(), Times.AtLeastOnce);
+ }
+
+ [Fact]
+ public void GetConstituents_WithNullQueryParams_StillMakesRequest()
+ {
+ _mockDataStorageService.Setup(x => x.GetAccessToken()).Returns("valid-access-token");
+ _mockAuthService.Setup(x => x.RefreshAccessToken()).Returns(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.GetConstituents(null);
+
+ Assert.NotNull(result);
+ _mockDataStorageService.Verify(x => x.GetAccessToken(), Times.AtLeastOnce);
+ }
+
+ [Fact]
+ public void CreateQueryParamsFromNextLinkUri_WithValidUri_ExtractsParameters()
+ {
+ var nextLinkUri = new Uri("https://api.sky.blackbaud.com/constituent/v1/constituents?last_modified=2023-01-01T00:00:00Z&sort_token=abc123");
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.CreateQueryParamsFromNextLinkUri(nextLinkUri);
+
+ Assert.NotNull(result);
+ Assert.Equal("2023-01-01T00:00:00Z", result.LastModified);
+ Assert.Equal("abc123", result.SortToken);
+ }
+
+ [Fact]
+ public void CreateQueryParamsFromNextLinkUri_WithUriWithoutParams_ReturnsEmptyParams()
+ {
+ var nextLinkUri = new Uri("https://api.sky.blackbaud.com/constituent/v1/constituents");
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.CreateQueryParamsFromNextLinkUri(nextLinkUri);
+
+ Assert.NotNull(result);
+ Assert.Null(result.LastModified);
+ Assert.Null(result.SortToken);
+ }
+
+ [Fact]
+ public void CreateQueryParamsFromNextLinkUri_WithOnlyLastModified_ExtractsCorrectly()
+ {
+ var nextLinkUri = new Uri("https://api.sky.blackbaud.com/constituent/v1/constituents?last_modified=2023-01-01T00:00:00Z");
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.CreateQueryParamsFromNextLinkUri(nextLinkUri);
+
+ Assert.NotNull(result);
+ Assert.Equal("2023-01-01T00:00:00Z", result.LastModified);
+ Assert.Null(result.SortToken);
+ }
+
+ [Fact]
+ public void CreateQueryParamsFromNextLinkUri_WithOnlySortToken_ExtractsCorrectly()
+ {
+ var nextLinkUri = new Uri("https://api.sky.blackbaud.com/constituent/v1/constituents?sort_token=abc123");
+
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ var result = service.CreateQueryParamsFromNextLinkUri(nextLinkUri);
+
+ Assert.NotNull(result);
+ Assert.Null(result.LastModified);
+ Assert.Equal("abc123", result.SortToken);
+ }
+
+ [Fact]
+ public void AppSettings_AreAccessedCorrectly()
+ {
+ var service = new ConstituentsService(
+ _mockAppSettings.Object,
+ _mockDataStorageService.Object,
+ _mockAuthService.Object);
+
+ _mockAppSettings.Verify(x => x.Value, Times.AtLeastOnce);
+ }
+ }
+}
diff --git a/tests/SyncAppTests.cs b/tests/SyncAppTests.cs
new file mode 100644
index 0000000..6c6bc31
--- /dev/null
+++ b/tests/SyncAppTests.cs
@@ -0,0 +1,146 @@
+using Blackbaud.HeadlessDataSync;
+using Blackbaud.HeadlessDataSync.Services;
+using Blackbaud.HeadlessDataSync.Services.DataSync;
+using Microsoft.Extensions.Logging;
+using Moq;
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Blackbaud.HeadlessDataSync.Tests
+{
+ public class SyncAppTests
+ {
+ private readonly Mock> _mockLogger;
+ private readonly Mock _mockDataSyncService;
+ private readonly Mock _mockDataStorageService;
+
+ public SyncAppTests()
+ {
+ _mockLogger = new Mock>();
+ _mockDataSyncService = new Mock();
+ _mockDataStorageService = new Mock();
+ }
+
+ [Fact]
+ public void Constructor_WithValidDependencies_CreatesInstance()
+ {
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithRefreshTokenArgument_ParsesCorrectly()
+ {
+ var refreshToken = "test-refresh-token";
+ var args = new[] { "--refreshtoken", refreshToken };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithShortRefreshTokenArgument_ParsesCorrectly()
+ {
+ var refreshToken = "test-refresh-token";
+ var args = new[] { "-r", refreshToken };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithoutRefreshTokenArgument_ParsesCorrectly()
+ {
+ var args = new string[] { };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Theory]
+ [InlineData("--refreshtoken", "token123")]
+ [InlineData("-r", "token456")]
+ [InlineData("--refreshtoken", "")]
+ public void Run_WithVariousRefreshTokenFormats_HandlesCorrectly(string flag, string token)
+ {
+ var args = new[] { flag, token };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithEmptyArgs_HandlesCorrectly()
+ {
+ var args = new string[] { };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithNullArgs_HandlesCorrectly()
+ {
+ string[] args = null;
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+
+ [Fact]
+ public void Run_WithInvalidArgs_HandlesCorrectly()
+ {
+ var args = new[] { "--invalid", "value" };
+
+ var syncApp = new SyncApp(
+ _mockLogger.Object,
+ _mockDataSyncService.Object,
+ _mockDataStorageService.Object);
+
+ syncApp.Run(args);
+
+ Assert.NotNull(syncApp);
+ }
+ }
+}
diff --git a/tests/skyapi-headless-data-sync.Tests.csproj b/tests/skyapi-headless-data-sync.Tests.csproj
new file mode 100644
index 0000000..c1c2fc9
--- /dev/null
+++ b/tests/skyapi-headless-data-sync.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+