using Xunit; using Moq; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.Json; using allstarr.Models.Domain; using allstarr.Models.Spotify; using allstarr.Services.Common; using allstarr.Models.Settings; namespace allstarr.Tests; public class RedisCacheServiceTests { private readonly Mock> _mockLogger; private readonly IOptions _settings; public RedisCacheServiceTests() { _mockLogger = new Mock>(); _settings = Options.Create(new RedisSettings { Enabled = false, // Disabled for unit tests to avoid requiring actual Redis ConnectionString = "localhost:6379" }); } private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions? settings = null) { return new RedisCacheService( settings ?? _settings, _mockLogger.Object, memoryCache ?? new MemoryCache(new MemoryCacheOptions())); } [Fact] public void Constructor_InitializesWithSettings() { // Act var service = CreateService(); // Assert Assert.NotNull(service); Assert.False(service.IsEnabled); // Should be disabled in tests } [Fact] public void Constructor_WithEnabledSettings_AttemptsConnection() { // Arrange var enabledSettings = Options.Create(new RedisSettings { Enabled = true, ConnectionString = "localhost:6379" }); // Act - Constructor will try to connect but should handle failure gracefully var service = CreateService(settings: enabledSettings); // Assert - Service should be created even if connection fails Assert.NotNull(service); } [Fact] public async Task GetStringAsync_WhenDisabled_ReturnsNull() { // Arrange var service = CreateService(); // Act var result = await service.GetStringAsync("test:key"); // Assert Assert.Null(result); } [Fact] public async Task GetAsync_WhenDisabled_ReturnsNull() { // Arrange var service = CreateService(); // Act var result = await service.GetAsync("test:key"); // Assert Assert.Null(result); } [Fact] public async Task SetStringAsync_WhenDisabled_ReturnsFalse() { // Arrange var service = CreateService(); // Act var result = await service.SetStringAsync("test:key", "test value"); // Assert Assert.False(result); } [Fact] public async Task SetAsync_WhenDisabled_ReturnsFalse() { // Arrange var service = CreateService(); var testObj = new TestObject { Id = 1, Name = "Test" }; // Act var result = await service.SetAsync("test:key", testObj); // Assert Assert.False(result); } [Fact] public async Task DeleteAsync_WhenDisabled_ReturnsFalse() { // Arrange var service = CreateService(); // Act var result = await service.DeleteAsync("test:key"); // Assert Assert.False(result); } [Fact] public async Task ExistsAsync_WhenDisabled_ReturnsFalse() { // Arrange var service = CreateService(); // Act var result = await service.ExistsAsync("test:key"); // Assert Assert.False(result); } [Fact] public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero() { // Arrange var service = CreateService(); // Act var result = await service.DeleteByPatternAsync("test:*"); // Assert Assert.Equal(0, result); } [Fact] public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan() { // Arrange var service = CreateService(); var expiry = TimeSpan.FromHours(1); // Act var result = await service.SetStringAsync("test:key", "value", expiry); // Assert - Should return false when disabled, but not throw Assert.False(result); } [Fact] public async Task SetAsync_WithExpiry_AcceptsTimeSpan() { // Arrange var service = CreateService(); var testObj = new TestObject { Id = 1, Name = "Test" }; var expiry = TimeSpan.FromDays(30); // Act var result = await service.SetAsync("test:key", testObj, expiry); // Assert - Should return false when disabled, but not throw Assert.False(result); } [Fact] public void IsEnabled_ReflectsSettings() { // Arrange var disabledService = CreateService(); var enabledSettings = Options.Create(new RedisSettings { Enabled = true, ConnectionString = "localhost:6379" }); var enabledService = CreateService(settings: enabledSettings); // Assert Assert.False(disabledService.IsEnabled); // enabledService.IsEnabled may be false if connection fails, which is expected } [Fact] public async Task GetAsync_DeserializesComplexObjects() { // Arrange var service = CreateService(); // Act var result = await service.GetAsync("test:complex"); // Assert Assert.Null(result); // Null when disabled } [Fact] public async Task SetAsync_SerializesComplexObjects() { // Arrange var service = CreateService(); var complexObj = new ComplexTestObject { Id = 1, Name = "Test", Items = new System.Collections.Generic.List { "Item1", "Item2" }, Metadata = new System.Collections.Generic.Dictionary { { "Key1", "Value1" }, { "Key2", "Value2" } } }; // Act var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1)); // Assert Assert.False(result); // False when disabled } [Fact] public void ConnectionString_IsConfigurable() { // Arrange var customSettings = Options.Create(new RedisSettings { Enabled = false, ConnectionString = "redis-server:6380,password=secret,ssl=true" }); // Act var service = CreateService(settings: customSettings); // Assert Assert.NotNull(service); } [Fact] public async Task SetStringAsync_WhenDisabled_CachesValueInMemory() { var service = CreateService(); var setResult = await service.SetStringAsync("test:key", "test value"); var cachedValue = await service.GetStringAsync("test:key"); Assert.False(setResult); Assert.Equal("test value", cachedValue); } [Fact] public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory() { var service = CreateService(); var expected = new TestObject { Id = 42, Name = "Tiered" }; var setResult = await service.SetAsync("test:object", expected); var cachedValue = await service.GetAsync("test:object"); Assert.False(setResult); Assert.NotNull(cachedValue); Assert.Equal(expected.Id, cachedValue.Id); Assert.Equal(expected.Name, cachedValue.Name); } [Fact] public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue() { var service = CreateService(); await service.SetStringAsync("test:key", "test value"); var exists = await service.ExistsAsync("test:key"); Assert.True(exists); } [Fact] public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry() { var service = CreateService(); await service.SetStringAsync("test:key", "test value"); var deleted = await service.DeleteAsync("test:key"); var cachedValue = await service.GetStringAsync("test:key"); Assert.False(deleted); Assert.Null(cachedValue); } [Fact] public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries() { var service = CreateService(); await service.SetStringAsync("search:one", "1"); await service.SetStringAsync("search:two", "2"); await service.SetStringAsync("other:one", "3"); var deletedCount = await service.DeleteByPatternAsync("search:*"); Assert.Equal(2, deletedCount); Assert.Null(await service.GetStringAsync("search:one")); Assert.Null(await service.GetStringAsync("search:two")); Assert.Equal("3", await service.GetStringAsync("other:one")); } [Fact] public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache() { var service = CreateService(); await service.SetStringAsync("image:test:key", "binary-ish"); var cachedValue = await service.GetStringAsync("image:test:key"); var exists = await service.ExistsAsync("image:test:key"); Assert.Null(cachedValue); Assert.False(exists); } [Fact] public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory() { var service = CreateService(); var songs = new List { CreateLocalSongWithRawJellyfinMetadata() }; var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs); var cachedValue = await service.GetAsync>("test:songs:raw-jellyfin"); Assert.False(setResult); Assert.NotNull(cachedValue); var roundTrippedSong = Assert.Single(cachedValue!); Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem)); Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString()); var mediaSources = Assert.IsType(roundTrippedSong.JellyfinMetadata!["MediaSources"]); Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind); Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64()); } [Fact] public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory() { var service = CreateService(); var matchedTracks = new List { new() { Position = 0, SpotifyId = "spotify-1", SpotifyTitle = "Track", SpotifyArtist = "Artist", MatchType = "fuzzy", MatchedSong = CreateLocalSongWithRawJellyfinMetadata() } }; var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks); var cachedValue = await service.GetAsync>("test:matched:raw-jellyfin"); Assert.False(setResult); Assert.NotNull(cachedValue); var roundTrippedMatch = Assert.Single(cachedValue!); Assert.True( JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem)); Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString()); var mediaSources = Assert.IsType(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]); Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind); Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString()); } private static Song CreateLocalSongWithRawJellyfinMetadata() { var song = new Song { Id = "song-1", Title = "Track", Artist = "Artist", Album = "Album", IsLocal = true }; using var doc = JsonDocument.Parse(""" { "Id": "song-1", "ServerId": "c17d351d3af24c678a6d8049c212d522", "RunTimeTicks": 2234068710, "MediaSources": [ { "Id": "song-1", "RunTimeTicks": 2234068710 } ] } """); JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement); song.JellyfinMetadata ??= new Dictionary(); song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize(doc.RootElement.GetProperty("MediaSources").GetRawText()); return song; } private class TestObject { public int Id { get; set; } public string Name { get; set; } = string.Empty; } private class ComplexTestObject { public int Id { get; set; } public string Name { get; set; } = string.Empty; public System.Collections.Generic.List Items { get; set; } = new(); public System.Collections.Generic.Dictionary Metadata { get; set; } = new(); } }