diff --git a/.gitignore b/.gitignore index c629877..a592bae 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ nunit-*.xml # mac files .DS_Store + +# Cache directories +cache/ diff --git a/Server/Program.cs b/Server/Program.cs index 89a6aa1..be58d91 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -10,7 +10,7 @@ var transportConfig = builder.Configuration.GetSection("Transport").Get(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Services/CachedTransportService.cs b/Services/CachedTransportService.cs index 36e7d88..f5cc9f4 100644 --- a/Services/CachedTransportService.cs +++ b/Services/CachedTransportService.cs @@ -1,9 +1,8 @@ -using Microsoft.Extensions.Caching.Memory; using ShiftScheduler.Shared; namespace ShiftScheduler.Services { - public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService + public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IPersistentCache cache) : ITransportService { public async Task GetConnectionAsync(DateTime shiftStartTime) { @@ -14,7 +13,8 @@ namespace ShiftScheduler.Services var cacheKey = GenerateCacheKey(searchDate, searchTime); // Try to get from cache first - if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null) + var cachedConnection = await cache.GetAsync(cacheKey); + if (cachedConnection != null) { return cachedConnection; } @@ -25,11 +25,7 @@ namespace ShiftScheduler.Services // Cache the connection result if valid if (connection != null) { - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays) - }; - cache.Set(cacheKey, connection, cacheOptions); + await cache.SetAsync(cacheKey, connection, TimeSpan.FromDays(config.CacheDurationDays)); } return connection; diff --git a/Services/FilePersistentCache.cs b/Services/FilePersistentCache.cs new file mode 100644 index 0000000..dfc4c3c --- /dev/null +++ b/Services/FilePersistentCache.cs @@ -0,0 +1,179 @@ +using System.Text.Json; + +namespace ShiftScheduler.Services +{ + public class FilePersistentCache : IPersistentCache + { + private readonly string _cacheDirectory; + private readonly string _indexFilePath; + private readonly object _lockObject = new object(); + + public FilePersistentCache(string cacheDirectory = "cache") + { + _cacheDirectory = Path.Combine(Directory.GetCurrentDirectory(), cacheDirectory); + _indexFilePath = Path.Combine(_cacheDirectory, "cache_index.json"); + EnsureCacheDirectoryExists(); + } + + public async Task GetAsync(string key) where T : class + { + try + { + var cacheFilePath = GetCacheFilePath(key); + if (!File.Exists(cacheFilePath)) + return null; + + var cacheEntryJson = await File.ReadAllTextAsync(cacheFilePath); + var cacheEntry = JsonSerializer.Deserialize(cacheEntryJson); + + if (cacheEntry == null || cacheEntry.ExpiresAt < DateTime.UtcNow) + { + // Cache entry expired, remove it + await RemoveAsync(key); + return null; + } + + return JsonSerializer.Deserialize(cacheEntry.Value); + } + catch + { + // If there's any error reading the cache, return null + return null; + } + } + + public async Task SetAsync(string key, T value, TimeSpan expiration) where T : class + { + try + { + var cacheEntry = new CacheEntry + { + Key = key, + Value = JsonSerializer.Serialize(value), + ExpiresAt = DateTime.UtcNow.Add(expiration), + CreatedAt = DateTime.UtcNow + }; + + var cacheFilePath = GetCacheFilePath(key); + var cacheEntryJson = JsonSerializer.Serialize(cacheEntry); + + lock (_lockObject) + { + EnsureCacheDirectoryExists(); + File.WriteAllText(cacheFilePath, cacheEntryJson); + } + + await UpdateIndexAsync(key, cacheEntry.ExpiresAt); + } + catch + { + // Fail silently if cache write fails + } + } + + public async Task RemoveAsync(string key) + { + try + { + var cacheFilePath = GetCacheFilePath(key); + if (File.Exists(cacheFilePath)) + { + File.Delete(cacheFilePath); + } + await RemoveFromIndexAsync(key); + } + catch + { + // Fail silently if removal fails + } + } + + public async Task ClearExpiredAsync() + { + try + { + var index = await LoadIndexAsync(); + var expiredKeys = index.Where(kvp => kvp.Value < DateTime.UtcNow).Select(kvp => kvp.Key).ToList(); + + foreach (var key in expiredKeys) + { + await RemoveAsync(key); + } + } + catch + { + // Fail silently if cleanup fails + } + } + + private string GetCacheFilePath(string key) + { + var safeKey = key.Replace(':', '_').Replace('/', '_').Replace('\\', '_'); + return Path.Combine(_cacheDirectory, $"{safeKey}.json"); + } + + private void EnsureCacheDirectoryExists() + { + if (!Directory.Exists(_cacheDirectory)) + { + Directory.CreateDirectory(_cacheDirectory); + } + } + + private async Task UpdateIndexAsync(string key, DateTime expiresAt) + { + try + { + var index = await LoadIndexAsync(); + index[key] = expiresAt; + + var indexJson = JsonSerializer.Serialize(index); + await File.WriteAllTextAsync(_indexFilePath, indexJson); + } + catch + { + // Fail silently if index update fails + } + } + + private async Task RemoveFromIndexAsync(string key) + { + try + { + var index = await LoadIndexAsync(); + index.Remove(key); + + var indexJson = JsonSerializer.Serialize(index); + await File.WriteAllTextAsync(_indexFilePath, indexJson); + } + catch + { + // Fail silently if index update fails + } + } + + private async Task> LoadIndexAsync() + { + try + { + if (!File.Exists(_indexFilePath)) + return new Dictionary(); + + var indexJson = await File.ReadAllTextAsync(_indexFilePath); + return JsonSerializer.Deserialize>(indexJson) ?? new Dictionary(); + } + catch + { + return new Dictionary(); + } + } + + private class CacheEntry + { + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } + } + } +} \ No newline at end of file diff --git a/Services/IPersistentCache.cs b/Services/IPersistentCache.cs new file mode 100644 index 0000000..e9159a6 --- /dev/null +++ b/Services/IPersistentCache.cs @@ -0,0 +1,10 @@ +namespace ShiftScheduler.Services +{ + public interface IPersistentCache + { + Task GetAsync(string key) where T : class; + Task SetAsync(string key, T value, TimeSpan expiration) where T : class; + Task RemoveAsync(string key); + Task ClearExpiredAsync(); + } +} \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs b/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs index aee4fd2..5e77c20 100644 --- a/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs +++ b/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Caching.Memory; using Moq; using ShiftScheduler.Services; using ShiftScheduler.Shared; @@ -9,14 +8,14 @@ namespace ShiftScheduler.Services.Tests; public class CachedTransportServiceTests { private readonly Mock _transportServiceMock; - private readonly IMemoryCache _memoryCache; + private readonly Mock _cacheMock; private readonly CachedTransportService _cachedTransportService; private readonly TransportConfiguration _config; public CachedTransportServiceTests() { _transportServiceMock = new Mock(); - _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _cacheMock = new Mock(); _config = new TransportConfiguration { @@ -30,7 +29,7 @@ public class CachedTransportServiceTests CacheDurationDays = 1 }; - _cachedTransportService = new CachedTransportService(_transportServiceMock.Object, _config, _memoryCache); + _cachedTransportService = new CachedTransportService(_transportServiceMock.Object, _config, _cacheMock.Object); } [Fact] @@ -46,6 +45,10 @@ public class CachedTransportServiceTests Platform = "5" }; + _cacheMock + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync((TransportConnection?)null); // First call returns null (not cached) + _transportServiceMock .Setup(x => x.GetConnectionAsync(shiftStartTime)) .ReturnsAsync(connection); @@ -53,6 +56,11 @@ public class CachedTransportServiceTests // Act - First call should hit the transport service var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime); + // Setup cache to return the connection for second call + _cacheMock + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(connection); + // Act - Second call should use cache var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime); @@ -62,8 +70,12 @@ public class CachedTransportServiceTests result1.ArrivalTime.ShouldBe(result2.ArrivalTime); result1.DepartureTime.ShouldBe(result2.DepartureTime); - // Verify that transport service was called only once + // Verify that transport service was called only once for the first call _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Once); + + // Verify that cache was accessed and set was called + _cacheMock.Verify(x => x.GetAsync(It.IsAny()), Times.AtLeastOnce); + _cacheMock.Verify(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -72,6 +84,10 @@ public class CachedTransportServiceTests // Arrange var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + _cacheMock + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync((TransportConnection?)null); + _transportServiceMock .Setup(x => x.GetConnectionAsync(shiftStartTime)) .ReturnsAsync((TransportConnection?)null); @@ -88,6 +104,9 @@ public class CachedTransportServiceTests // Verify that transport service was called twice (no caching for null) _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Exactly(2)); + + // Verify that cache set was never called for null values + _cacheMock.Verify(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -100,6 +119,10 @@ public class CachedTransportServiceTests var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" }; var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" }; + _cacheMock + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync((TransportConnection?)null); // Cache misses for both calls + _transportServiceMock .Setup(x => x.GetConnectionAsync(shiftStartTime1)) .ReturnsAsync(connection1); @@ -120,5 +143,8 @@ public class CachedTransportServiceTests // Verify that transport service was called twice (different cache keys) _transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny()), Times.Exactly(2)); + + // Verify that cache set was called twice (for different cache keys) + _cacheMock.Verify(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } } \ No newline at end of file