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 + + + + + + + + + + + + + +