diff --git a/src/SpotifyDaily.Worker/Services/Contracts/ISpotifyClientService.cs b/src/SpotifyDaily.Worker/Services/Contracts/ISpotifyClientService.cs index c51882e..c168c99 100644 --- a/src/SpotifyDaily.Worker/Services/Contracts/ISpotifyClientService.cs +++ b/src/SpotifyDaily.Worker/Services/Contracts/ISpotifyClientService.cs @@ -6,5 +6,5 @@ namespace SpotifyDaily.Worker.Services.Contracts; public interface ISpotifyClientService { Task ConfigureNewClientAsync(string code, CancellationToken cancellationToken); - Task GetClientAsync(string? code = null, CancellationToken cancellationToken = default); + Task GetClientAsync(string? code = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/SpotifyDaily.Worker/Services/PlaylistService.cs b/src/SpotifyDaily.Worker/Services/PlaylistService.cs index b0317bc..a0dc75e 100644 --- a/src/SpotifyDaily.Worker/Services/PlaylistService.cs +++ b/src/SpotifyDaily.Worker/Services/PlaylistService.cs @@ -11,7 +11,7 @@ public class PlaylistService(ILogger logger, IOptions topTracks, string playlistId, SpotifyClient client, CancellationToken cancellationToken) + private async Task AddTracksAsync(IEnumerable topTracks, string playlistId, ISpotifyClient client, CancellationToken cancellationToken) { logger.LogInformation("Adding top tracks to the playlist..."); @@ -69,7 +69,7 @@ private async Task AddTracksAsync(IEnumerable topTracks, string playl logger.LogInformation("Added {Count} tracks to the playlist.", topTracks.Count()); } - private async Task> GetTopTracksAsync(SpotifyClient client, CancellationToken cancellationToken) + private async Task> GetTopTracksAsync(ISpotifyClient client, CancellationToken cancellationToken) { logger.LogInformation("Fetching top tracks..."); @@ -79,7 +79,7 @@ private async Task> GetTopTracksAsync(SpotifyClient clien return topTracksResponse.Items; } - private async Task RemoveCurrentTracksAsync(SpotifyClient client, string playlistId, CancellationToken cancellationToken) + private async Task RemoveCurrentTracksAsync(ISpotifyClient client, string playlistId, CancellationToken cancellationToken) { IEnumerable? currentTracks = await GetCurrentPlaylistTracksAsync(client, playlistId, cancellationToken); if (currentTracks == null || !currentTracks.Any()) @@ -99,7 +99,7 @@ private async Task RemoveCurrentTracksAsync(SpotifyClient client, string playlis logger.LogInformation("Current tracks removed from the playlist."); } - private async Task?> GetCurrentPlaylistTracksAsync(SpotifyClient client, string playlistId, CancellationToken cancellationToken) + private async Task?> GetCurrentPlaylistTracksAsync(ISpotifyClient client, string playlistId, CancellationToken cancellationToken) { Paging> playlistItems = await client.Playlists.GetItems(playlistId, new PlaylistGetItemsRequest(PlaylistGetItemsRequest.AdditionalTypes.Track), cancellationToken); if (playlistItems.Items == null) diff --git a/src/SpotifyDaily.Worker/Services/SpotifyClientService.cs b/src/SpotifyDaily.Worker/Services/SpotifyClientService.cs index 71a452b..7c94eaa 100644 --- a/src/SpotifyDaily.Worker/Services/SpotifyClientService.cs +++ b/src/SpotifyDaily.Worker/Services/SpotifyClientService.cs @@ -92,7 +92,7 @@ private async Task SaveTokensAsync(string accessToken, string? refreshToken, Dat } - public async Task GetClientAsync(string? code = null, CancellationToken cancellationToken = default) + public async Task GetClientAsync(string? code = null, CancellationToken cancellationToken = default) { if (_appConfig.ExpireDate > DateTime.Now && !string.IsNullOrWhiteSpace(_appConfig.Token)) diff --git a/src/SpotifyDaily.sln b/src/SpotifyDaily.sln index 9e6542b..e70c7e8 100644 --- a/src/SpotifyDaily.sln +++ b/src/SpotifyDaily.sln @@ -10,6 +10,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{63914694-B031-4C79-BD7D-4262D8421F9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpotifyDaily.Tests.Worker", "Tests\SpotifyDaily.Tests.Worker\SpotifyDaily.Tests.Worker.csproj", "{8A547896-F80D-4340-A692-632F8FABF4CC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,8 +36,26 @@ Global {27EDCFDA-40EE-4DC9-962A-5A44A97F499B}.Release|x64.Build.0 = Release|Any CPU {27EDCFDA-40EE-4DC9-962A-5A44A97F499B}.Release|x86.ActiveCfg = Release|Any CPU {27EDCFDA-40EE-4DC9-962A-5A44A97F499B}.Release|x86.Build.0 = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|x64.Build.0 = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Debug|x86.Build.0 = Debug|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|Any CPU.Build.0 = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|x64.ActiveCfg = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|x64.Build.0 = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|x86.ActiveCfg = Release|Any CPU + {8A547896-F80D-4340-A692-632F8FABF4CC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8A547896-F80D-4340-A692-632F8FABF4CC} = {63914694-B031-4C79-BD7D-4262D8421F9B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D58A2A72-DA7F-4FA2-9FFE-8890A9620400} + EndGlobalSection EndGlobal diff --git a/src/Tests/SpotifyDaily.Tests.Worker/Attributes/AutoMoqDataAttribute.cs b/src/Tests/SpotifyDaily.Tests.Worker/Attributes/AutoMoqDataAttribute.cs new file mode 100644 index 0000000..deff70f --- /dev/null +++ b/src/Tests/SpotifyDaily.Tests.Worker/Attributes/AutoMoqDataAttribute.cs @@ -0,0 +1,23 @@ +using AutoFixture; +using AutoFixture.AutoMoq; +using AutoFixture.Xunit2; +using SpotifyAPI.Web; + +namespace SpotifyDaily.Tests.Worker.Attributes; + +public class AutoMoqDataAttribute : AutoDataAttribute +{ + public AutoMoqDataAttribute() : base(() => + { + var fixture = new Fixture(); + fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true, + GenerateDelegates = true + }); + fixture.Customize(sb => sb.FromFactory(() => new SpotifyClient("token"))); + return fixture; + }) + { + } +} diff --git a/src/Tests/SpotifyDaily.Tests.Worker/Fakes/FakeSpotifyClient.cs b/src/Tests/SpotifyDaily.Tests.Worker/Fakes/FakeSpotifyClient.cs new file mode 100644 index 0000000..c7deea2 --- /dev/null +++ b/src/Tests/SpotifyDaily.Tests.Worker/Fakes/FakeSpotifyClient.cs @@ -0,0 +1,109 @@ +using SpotifyAPI.Web; +using SpotifyAPI.Web.Http; + +public class FakeSpotifyClient : ISpotifyClient +{ + public IPlaylistsClient Playlists { get; } = default!; + public IUserProfileClient UserProfile { get; } = default!; + + // Add any other required properties from ISpotifyClient interface with default implementations + + public FakeSpotifyClient() + { + + } + + public FakeSpotifyClient(IPlaylistsClient playlists, IUserProfileClient userProfile) + { + Playlists = playlists; + UserProfile = userProfile; + } + + + public AlbumsClient Albums => throw new NotImplementedException(); + public ArtistsClient Artists => throw new NotImplementedException(); + public BrowseClient Browse => throw new NotImplementedException(); + + public IPaginator DefaultPaginator => throw new NotImplementedException(); + + IUserProfileClient ISpotifyClient.UserProfile => UserProfile; + + IBrowseClient ISpotifyClient.Browse => Browse; + + public IShowsClient Shows => throw new NotImplementedException(); + + IPlaylistsClient ISpotifyClient.Playlists => Playlists; + + public ISearchClient Search => throw new NotImplementedException(); + + public IFollowClient Follow => throw new NotImplementedException(); + + public ITracksClient Tracks => throw new NotImplementedException(); + + public IPlayerClient Player => throw new NotImplementedException(); + + IAlbumsClient ISpotifyClient.Albums => Albums; + + IArtistsClient ISpotifyClient.Artists => Artists; + + public IPersonalizationClient Personalization => throw new NotImplementedException(); + + public IEpisodesClient Episodes => throw new NotImplementedException(); + + public ILibraryClient Library => throw new NotImplementedException(); + + public IAudiobooksClient Audiobooks => throw new NotImplementedException(); + + public IChaptersClient Chapters => throw new NotImplementedException(); + + public IResponse? LastResponse => throw new NotImplementedException(); + + // Add other required properties with placeholder implementations + + public void Dispose() { } + + public Task> PaginateAll(IPaginatable firstPage, IPaginator? paginator = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> PaginateAll(IPaginatable firstPage, Func> mapper, IPaginator? paginator = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable Paginate(IPaginatable firstPage, IPaginator? paginator = null, CancellationToken cancel = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable Paginate(IPaginatable firstPage, Func> mapper, IPaginator? paginator = null, CancellationToken cancel = default) + { + throw new NotImplementedException(); + } + + public Task> NextPage(Paging paging) + { + throw new NotImplementedException(); + } + + public Task> NextPage(CursorPaging cursorPaging) + { + throw new NotImplementedException(); + } + + public Task NextPage(IPaginatable paginatable) + { + throw new NotImplementedException(); + } + + public Task> PreviousPage(Paging paging) + { + throw new NotImplementedException(); + } + + public Task PreviousPage(Paging paging) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Tests/SpotifyDaily.Tests.Worker/Services/AppConfigServiceTests.cs b/src/Tests/SpotifyDaily.Tests.Worker/Services/AppConfigServiceTests.cs new file mode 100644 index 0000000..abeb659 --- /dev/null +++ b/src/Tests/SpotifyDaily.Tests.Worker/Services/AppConfigServiceTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Moq; +using SpotifyDaily.Worker.Models; +using SpotifyDaily.Worker.Services; +using System.Text.Json; + +namespace SpotifyDaily.Tests.Worker.Services; + +public class AppConfigServiceTests : IDisposable +{ + private readonly Mock _mockEnvironment; + private readonly Mock _mockConfiguration; + private readonly Mock> _mockOptionsMonitor; + private readonly string _tempDir; + private readonly string _tempFile; + private readonly AppConfig _initialConfig; + + public AppConfigServiceTests() + { + _initialConfig = new AppConfig + { + LastRun = DateTime.Now.AddDays(-1), + Token = "test-token", + RefreshToken = "test-refresh-token", + ExpireDate = DateTime.Now.AddDays(1) + }; + + _mockEnvironment = new Mock(); + _mockConfiguration = new Mock(); + _mockOptionsMonitor = new Mock>(); + _mockOptionsMonitor.Setup(m => m.CurrentValue).Returns(_initialConfig); + + // Setup temp directory for tests + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + _tempFile = Path.Combine(_tempDir, "appconfig.json"); + + // Setup mock environment to return our temp directory + _mockEnvironment.Setup(e => e.ContentRootPath).Returns(_tempDir); + } + + [Fact] + public void Constructor_InitializesProperties() + { + // Arrange & Act + var service = CreateService(); + + // Assert + Assert.Equal(_initialConfig, service.Current); + } + + [Fact] + public void Current_Get_ReturnsCurrentConfig() + { + // Arrange + var service = CreateService(); + + // Act + var result = service.Current; + + // Assert + Assert.Equal(_initialConfig, result); + } + + [Fact] + public void Current_Set_UpdatesConfigAndRaisesEvent() + { + // Arrange + var service = CreateService(); + var newConfig = new AppConfig { Token = "new-token" }; + var eventRaised = false; + service.OnChange += config => eventRaised = config.Token == "new-token"; + + // Act + service.Current = newConfig; + + // Assert + Assert.True(eventRaised, "OnChange event should be raised"); + Assert.Equal("new-token", service.Current.Token); + Assert.True(File.Exists(_tempFile), "Config file should be created"); + + // Verify file contents + var fileContent = File.ReadAllText(_tempFile); + using var doc = JsonDocument.Parse(fileContent); + var appConfigJson = doc.RootElement.GetProperty("AppConfig").GetRawText(); + var deserializedContent = JsonSerializer.Deserialize(appConfigJson); + Assert.NotNull(deserializedContent); + Assert.Equal("new-token", deserializedContent!.Token); + } + + [Fact] + public async Task UpdateAsync_WritesConfigToFile() + { + // Arrange + var service = CreateService(); + var newConfig = new AppConfig + { + LastRun = DateTime.Now, + Token = "updated-token", + RefreshToken = "updated-refresh" + }; + + // Act + await service.UpdateAsync(newConfig); + + // Assert + Assert.True(File.Exists(_tempFile), "Config file should be created"); + + // Verify file contents + var fileContent = File.ReadAllText(_tempFile); + using var doc = JsonDocument.Parse(fileContent); + var appConfigJson = doc.RootElement.GetProperty("AppConfig").GetRawText(); + var deserializedContent = JsonSerializer.Deserialize(appConfigJson); + Assert.NotNull(deserializedContent); + Assert.Equal("updated-token", deserializedContent!.Token); + } + + private AppConfigService CreateService() + { + return new AppConfigService( + _mockEnvironment.Object, + _mockConfiguration.Object, + _mockOptionsMonitor.Object); + } + + public void Dispose() + { + // Clean up temp files + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + catch { /* Ignore cleanup errors */ } + } +} diff --git a/src/Tests/SpotifyDaily.Tests.Worker/Services/PlaylistServiceTests.cs b/src/Tests/SpotifyDaily.Tests.Worker/Services/PlaylistServiceTests.cs new file mode 100644 index 0000000..9516e3c --- /dev/null +++ b/src/Tests/SpotifyDaily.Tests.Worker/Services/PlaylistServiceTests.cs @@ -0,0 +1,140 @@ +using AutoFixture; +using AutoFixture.Xunit2; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SpotifyAPI.Web; +using SpotifyDaily.Tests.Worker.Attributes; +using SpotifyDaily.Worker.Exceptions; +using SpotifyDaily.Worker.Options; +using SpotifyDaily.Worker.Services; +using SpotifyDaily.Worker.Services.Contracts; + +namespace SpotifyDaily.Tests.Worker.Services; + +public class PlaylistServiceTests +{ + private SpotifyOptions CreateSpotifyOptions(string? playlistId = "playlist123") + { + return new SpotifyOptions + { + ClientId = "clientId", + ClientSecret = "clientSecret", + PlaylistId = playlistId, + RedirectURI = "http://localhost/callback" + }; + } + + [Theory] + [AutoMoqData] + public async Task UpdateDailyPlaylistAsync_ShouldThrowException_WhenSpotifyClientIsNotConfigured( + [Frozen] Mock spotifyClientServiceMock, + [Frozen] Mock> spotifyOptionsMock, + [Frozen] Mock> loggerMock) + { + // Arrange + _ = spotifyClientServiceMock.Setup(x => x.GetClientAsync(null, It.IsAny())) + .ThrowsAsync(new PlaylistServiceException("Spotify client is not configured. Please call ConfigureClientAsync first.")); + + _ = spotifyOptionsMock.Setup(x => x.Value).Returns(CreateSpotifyOptions()); + + PlaylistService service = new( + loggerMock.Object, + spotifyOptionsMock.Object, + spotifyClientServiceMock.Object); + + // Act & Assert + _ = await Assert.ThrowsAsync( + () => service.UpdateDailyPlaylistAsync()); + } + + [Theory] + [AutoMoqData] + public async Task UpdateDailyPlaylistAsync_ShouldThrowException_WhenPlaylistIdIsMissing( + [Frozen] Mock spotifyClientMock, + [Frozen] Mock spotifyClientServiceMock, + [Frozen] Mock> loggerMock, + [Frozen] Mock> spotifyOptionsMock) + { + // Arrange + _ = spotifyClientServiceMock.Setup(x => x.GetClientAsync(null, It.IsAny())) + .ReturnsAsync(spotifyClientMock.Object); + + _ = spotifyOptionsMock.Setup(x => x.Value).Returns(CreateSpotifyOptions(playlistId: null)); + + PlaylistService service = new( + loggerMock.Object, + spotifyOptionsMock.Object, + spotifyClientServiceMock.Object); + + // Act & Assert + _ = await Assert.ThrowsAsync( + () => service.UpdateDailyPlaylistAsync()); + } + + [Theory] + [AutoMoqData] + public async Task UpdateDailyPlaylistAsync_ShouldCallAllMethods_WhenConfigurationIsValid( + Fixture fixture, + [Frozen] Mock spotifyClientServiceMock, + [Frozen] Mock playlistsClientMock, + [Frozen] Mock userProfileClientMock, + [Frozen] Mock> spotifyOptionsMock, + [Frozen] Mock> loggerMock) + { + // Arrange + string playlistId = "playlist123"; + FullTrack fullTrack = fixture.Freeze(); + List topTracks = new() + { fullTrack }; + List playlistTracks = new() + { fullTrack }; + + // Create the fake client with the mocked interfaces + FakeSpotifyClient fakeClient = new( + playlistsClientMock.Object, + userProfileClientMock.Object); + + // Setup client service to return our fake client + _ = spotifyClientServiceMock.Setup(x => x.GetClientAsync(null, It.IsAny())) + .ReturnsAsync(fakeClient); + + _ = spotifyOptionsMock.Setup(x => x.Value).Returns(CreateSpotifyOptions(playlistId)); + + _ = playlistsClientMock.Setup(x => x.GetItems(playlistId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Paging> + { + Items = playlistTracks.Select(t => new PlaylistTrack { Track = t }).ToList() + }); + + _ = playlistsClientMock.Setup(x => x.RemoveItems(playlistId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new SnapshotResponse()); + + _ = userProfileClientMock.Setup(x => x.GetTopTracks(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UsersTopTracksResponse + { + Items = topTracks + }); + + _ = playlistsClientMock.Setup(x => x.ReplaceItems(playlistId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new SnapshotResponse()); + + _ = playlistsClientMock.Setup(x => x.ChangeDetails(playlistId, It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + PlaylistService service = new( + loggerMock.Object, + spotifyOptionsMock.Object, + spotifyClientServiceMock.Object); + + // Act + await service.UpdateDailyPlaylistAsync(); + + // Assert + playlistsClientMock.Verify(x => x.GetItems(playlistId, It.IsAny(), It.IsAny()), Times.Once); + playlistsClientMock.Verify(x => x.RemoveItems(playlistId, It.IsAny(), It.IsAny()), Times.Once); + userProfileClientMock.Verify(x => x.GetTopTracks(It.IsAny(), It.IsAny()), Times.Once); + playlistsClientMock.Verify(x => x.ReplaceItems(playlistId, It.IsAny(), It.IsAny()), Times.Once); + playlistsClientMock.Verify(x => x.ChangeDetails(playlistId, It.IsAny(), It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/src/Tests/SpotifyDaily.Tests.Worker/SpotifyDaily.Tests.Worker.csproj b/src/Tests/SpotifyDaily.Tests.Worker/SpotifyDaily.Tests.Worker.csproj new file mode 100644 index 0000000..c8dea85 --- /dev/null +++ b/src/Tests/SpotifyDaily.Tests.Worker/SpotifyDaily.Tests.Worker.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + +