diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index a3e84b0..5931fc0 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -347,7 +347,7 @@ public async Task GetTranslations_ShouldNotFail_WhenCachingReturnsNull() { // Arrange _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, false)).ReturnsAsync(CacheResponse.Empty); - _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)).ReturnsAsync(TestData.Translations_De); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)).ReturnsAsync(TestData.Http_Translations_De); var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); // Act @@ -386,7 +386,7 @@ public async Task GetTranslationsAsync_ShouldFallbackToReferenceLanguage_WhenFal _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, settings.IncludeDrafts)).Throws(); _cachingService.Setup(x => x.GetTranslations(referenceLanguage, settings.IncludeDrafts)).ReturnsAsync(TestData.Cache_Translations_En); _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)).Throws(); - _consumerHttpClient.Setup(x => x.GetTranslationsAsync(referenceLanguage)).ReturnsAsync(TestData.Translations_En); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(referenceLanguage)).ReturnsAsync(new TranslationsHttpResponse(TestData.Translations_En, null, false)); _cachingService.Setup(x => x.GetLanguages()) .ReturnsAsync(new CachedLanguageListResponse(new List { new Language { Name = referenceLanguage, IsReference = true } }, true)); @@ -507,16 +507,21 @@ private static CalingaServiceSettings CreateSettings(bool isDevMode = false) } [TestMethod] - public async Task GetTranslationsAsync_ShouldBypassCache_WhenInvalidateCacheIsTrue() + public async Task GetTranslationsAsync_InvalidateCache_ReturnsBodyFromHttp_NotFromCache() { - // Arrange + // Arrange — invalidateCache=true skips the fast-path return so the + // body comes from HTTP. The cache is still read (to surface a + // possible ETag), but its body is not returned directly. + // Default Init() makes the cache return Translations_De with no ETag. var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); - _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)).ReturnsAsync(TestData.Translations_De); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)) + .ReturnsAsync(new TranslationsHttpResponse(TestData.Translations_En, etag: null, notModified: false)); + // Act var translations = await service.GetTranslationsAsync(TestData.Language_DE, invalidateCache: true); + // Assert - translations.Should().BeEquivalentTo(TestData.Translations_De); - _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + translations.Should().BeEquivalentTo(TestData.Translations_En); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE), Times.Once); } @@ -530,7 +535,6 @@ public async Task GetTranslationsAsync_ShouldThrow_WhenInvalidateCacheIsTrue_And Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, invalidateCache: true); // Assert await act.Should().ThrowAsync(); - _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE), Times.Once); } @@ -828,5 +832,160 @@ public async Task GetTranslationsAsync_WithKeyList_NotDevMode_ServerOmitsKey_Sti } #endregion Keyed GetTranslationsAsync + + #region ETag revalidation + + [TestMethod] + public async Task GetTranslationsAsync_StaleCache_ServerReturns304_ReturnsCachedAndRefreshesExpiration() + { + // Arrange — cache hit but expired; the entry's stored ETag drives + // a conditional GET. Server confirms "still fresh" with 304, so we + // reuse the cached translations and call StoreTranslationsAsync to + // refresh the expiration timer. + const string cachedETag = "\"abc\""; + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundInCache: true, etag: cachedETag, isStale: true); + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(staleCacheResponse); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE, cachedETag)) + .ReturnsAsync(TranslationsHttpResponse.NotModifiedResponse(cachedETag)); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + result.Should().BeEquivalentTo(TestData.Translations_De); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, cachedETag), Times.Once); + _cachingService.Verify(x => x.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, cachedETag), Times.Once); + } + + [TestMethod] + public async Task GetTranslationsAsync_StaleCache_ServerReturns200_StoresNewTranslationsWithNewETag() + { + // Arrange — cache stale with old ETag; server returns fresh body and + // a new ETag. We must use the new data and persist the new ETag, + // not the old one (otherwise the next revalidation sends a stale tag). + const string oldETag = "\"old\""; + const string newETag = "\"new\""; + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundInCache: true, etag: oldETag, isStale: true); + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(staleCacheResponse); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE, oldETag)) + .ReturnsAsync(new TranslationsHttpResponse(TestData.Translations_En, newETag, notModified: false)); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + result.Should().BeEquivalentTo(TestData.Translations_En); + _cachingService.Verify(x => x.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_En, newETag), Times.Once); + } + + [TestMethod] + public async Task GetTranslationsAsync_CacheMiss_DoesNotSendIfNoneMatch() + { + // Arrange — empty cache: no ETag to send. Must hit the no-revalidation + // overload, not the 2-arg one with a null/empty ETag (the server-side + // contract is "include If-None-Match only if you have one"). + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(CacheResponse.Empty); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)) + .ReturnsAsync(TestData.Http_Translations_De); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE), Times.Once); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_FreshCache_DoesNotHitHttp() + { + // Arrange — fresh cache hit must short-circuit; no HTTP at all. + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(new CacheResponse(TestData.Translations_De, foundInCache: true, etag: "\"abc\"", isStale: false)); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_InvalidateCache_StillSendsIfNoneMatch_WhenCachedETagAvailable() + { + // Arrange — invalidateCache means "refresh the body", not "skip + // revalidation". The cached ETag is still useful: if the server + // returns 304, we know our cache body is the current truth and + // can serve it without a full download. + const string cachedETag = "\"abc\""; + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(new CacheResponse(TestData.Translations_De, foundInCache: true, etag: cachedETag, isStale: false)); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE, cachedETag)) + .ReturnsAsync(new TranslationsHttpResponse(TestData.Translations_En, "\"new\"", notModified: false)); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + await service.GetTranslationsAsync(TestData.Language_DE, invalidateCache: true); + + // Assert + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, cachedETag), Times.Once); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_UseCacheOnly_StaleData_ReturnsStaleWithoutHttp() + { + // Arrange — UseCacheOnly forbids HTTP. If the cache holds anything + // (fresh or stale), surface it. Skipping it would force callers + // offline to lose all translations after the first expiry. + var settings = CreateSettings(); + settings.UseCacheOnly = true; + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundInCache: true, etag: "\"abc\"", isStale: true); + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, settings.IncludeDrafts)) + .ReturnsAsync(staleCacheResponse); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + result.Should().BeEquivalentTo(TestData.Translations_De); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_CacheReportsMiss_DoesNotCrash_AndSkipsIfNoneMatch() + { + // Arrange — simulates the on-disk orphan-ETag scenario at the + // service level: even if a sidecar exists, FileCachingService + // returns a clean miss when the .json is gone. CalingaService + // must accept that, fall through to a plain GET (no + // If-None-Match), and return the server's response without + // throwing. + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, _testCalingaServiceSettings.IncludeDrafts)) + .ReturnsAsync(CacheResponse.Empty); + _consumerHttpClient.Setup(x => x.GetTranslationsAsync(TestData.Language_DE)) + .ReturnsAsync(TestData.Http_Translations_De); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + await act.Should().NotThrowAsync(); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE), Times.Once); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion ETag revalidation } } diff --git a/Calinga.NET.Tests/Context/TestContext.cs b/Calinga.NET.Tests/Context/TestContext.cs index 00827c2..930ccb1 100644 --- a/Calinga.NET.Tests/Context/TestContext.cs +++ b/Calinga.NET.Tests/Context/TestContext.cs @@ -1,143 +1,143 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Web; - -using Moq; -using Moq.Protected; -using System.Text.Json; - -using Calinga.NET.Caching; -using Calinga.NET.Infrastructure; - -namespace Calinga.NET.Tests.Context -{ - public class TestContext - { - private readonly Dictionary _repositories = new Dictionary(); - - public CalingaServiceSettings Settings { get; } - public Exception LastException { get; private set; } - public object LastResult { get; private set; } - private ICalingaService _service; - - public ICalingaService Service => _service ??= BuildCalingaService(); - - public TranslationsRepository this[string repository] => _repositories[repository]; - - public TestContext() - { - Settings = new CalingaServiceSettings - { - CacheDirectory = AppDomain.CurrentDomain.BaseDirectory ?? string.Empty, - Organization = Guid.NewGuid().ToString(), - Team = Guid.NewGuid().ToString(), - Project = Guid.NewGuid().ToString() - }; - - _repositories.Add("Calinga", new TranslationsRepository()); - _repositories.Add("Cache", new TranslationsRepository()); - } - - public async Task Try(Func> action) - { - try - { - LastResult = await action().ConfigureAwait(false); - LastException = null; - } - catch (Exception e) - { - LastException = e; - } - } - - private ICalingaService BuildCalingaService() - { - var httpClient = BuildHttpClientMock(); - - var fileService = BuildFileCachingServiceMock(); - - var cachingService = new CascadedCachingService(new InMemoryCachingService(new DateTimeService(), Settings), fileService.Object); - var consumerHttpClient = new ConsumerHttpClient(Settings, httpClient); - - return new CalingaService(cachingService, consumerHttpClient, Settings); - } - - private Mock BuildFileCachingServiceMock() - { - var fileService = new Mock(); - fileService.Setup(x => x.GetTranslations(It.IsAny(), It.IsAny())).ReturnsAsync( - (string languageName, bool isDraft) => - { - if ( - !this["Cache"].Organizations.ContainsKey(Settings.Organization) || - !this["Cache"].Organizations[Settings.Organization] - .ContainsKey(Settings.Team) || - !this["Cache"].Organizations[Settings.Organization][ - this.Settings.Team].ContainsKey(Settings.Project)) - { - return CacheResponse.Empty; - } - - return new CacheResponse(this["Cache"].Organizations[Settings.Organization][ - Settings.Team][ - Settings.Project][languageName], true); - }); - - fileService.Setup(f => f.ClearCache()).Callback(() => - { - this["Cache"].Organizations.Clear(); - }); - return fileService; - } - - private HttpClient BuildHttpClientMock() - { - var messageHandler = new Mock(MockBehavior.Strict); - messageHandler.Protected().Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()).ReturnsAsync( - (HttpRequestMessage request, CancellationToken _) => - { - if (request.Method == HttpMethod.Get) - { - var segments = request.RequestUri.Segments.Select(s => s.Trim('/')) - .Select(HttpUtility.UrlDecode).ToArray(); - var organizationName = segments[2]; - var teamName = segments[3]; - var projectName = segments[4]; - var languageName = segments[6]; - try - { - var translations = - this["Calinga"].Organizations - [organizationName][teamName][projectName][languageName]; - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonSerializer.Serialize(translations)) - }; - } - catch (Exception) - { - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.NotFound, - Content = new StringContent("not found") - }; - } - } - - return new HttpResponseMessage - { StatusCode = HttpStatusCode.NotImplemented, Content = new StringContent("{}") }; - }); - var httpClient = new HttpClient(messageHandler.Object); - return httpClient; - } - } +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +using Moq; +using Moq.Protected; +using System.Text.Json; + +using Calinga.NET.Caching; +using Calinga.NET.Infrastructure; + +namespace Calinga.NET.Tests.Context +{ + public class TestContext + { + private readonly Dictionary _repositories = new Dictionary(); + + public CalingaServiceSettings Settings { get; } + public Exception LastException { get; private set; } + public object LastResult { get; private set; } + private ICalingaService _service; + + public ICalingaService Service => _service ??= BuildCalingaService(); + + public TranslationsRepository this[string repository] => _repositories[repository]; + + public TestContext() + { + Settings = new CalingaServiceSettings + { + CacheDirectory = AppDomain.CurrentDomain.BaseDirectory ?? string.Empty, + Organization = Guid.NewGuid().ToString(), + Team = Guid.NewGuid().ToString(), + Project = Guid.NewGuid().ToString() + }; + + _repositories.Add("Calinga", new TranslationsRepository()); + _repositories.Add("Cache", new TranslationsRepository()); + } + + public async Task Try(Func> action) + { + try + { + LastResult = await action().ConfigureAwait(false); + LastException = null; + } + catch (Exception e) + { + LastException = e; + } + } + + private ICalingaService BuildCalingaService() + { + var httpClient = BuildHttpClientMock(); + + var fileService = BuildFileCachingServiceMock(); + + var cachingService = new CascadedCachingService(new InMemoryCachingService(new DateTimeService(), Settings), fileService.Object); + var consumerHttpClient = new ConsumerHttpClient(Settings, httpClient); + + return new CalingaService(cachingService, consumerHttpClient, Settings); + } + + private Mock BuildFileCachingServiceMock() + { + var fileService = new Mock(); + fileService.Setup(x => x.GetTranslations(It.IsAny(), It.IsAny())).ReturnsAsync( + (string languageName, bool isDraft) => + { + if ( + !this["Cache"].Organizations.ContainsKey(Settings.Organization) || + !this["Cache"].Organizations[Settings.Organization] + .ContainsKey(Settings.Team) || + !this["Cache"].Organizations[Settings.Organization][ + this.Settings.Team].ContainsKey(Settings.Project)) + { + return CacheResponse.Empty; + } + + return new CacheResponse(this["Cache"].Organizations[Settings.Organization][ + Settings.Team][ + Settings.Project][languageName], true); + }); + + fileService.Setup(f => f.ClearCache()).Callback(() => + { + this["Cache"].Organizations.Clear(); + }); + return fileService; + } + + private HttpClient BuildHttpClientMock() + { + var messageHandler = new Mock(MockBehavior.Strict); + messageHandler.Protected().Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()).ReturnsAsync( + (HttpRequestMessage request, CancellationToken _) => + { + if (request.Method == HttpMethod.Get) + { + var segments = request.RequestUri.Segments.Select(s => s.Trim('/')) + .Select(HttpUtility.UrlDecode).ToArray(); + var organizationName = segments[2]; + var teamName = segments[3]; + var projectName = segments[4]; + var languageName = segments[6]; + try + { + var translations = + this["Calinga"].Organizations + [organizationName][teamName][projectName][languageName]; + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(translations)) + }; + } + catch (Exception) + { + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.NotFound, + Content = new StringContent("not found") + }; + } + } + + return new HttpResponseMessage + { StatusCode = HttpStatusCode.NotImplemented, Content = new StringContent("{}") }; + }); + var httpClient = new HttpClient(messageHandler.Object); + return httpClient; + } + } } \ No newline at end of file diff --git a/Calinga.NET.Tests/FileCachingServiceTests.cs b/Calinga.NET.Tests/FileCachingServiceTests.cs index b692f44..525c5cd 100644 --- a/Calinga.NET.Tests/FileCachingServiceTests.cs +++ b/Calinga.NET.Tests/FileCachingServiceTests.cs @@ -153,7 +153,7 @@ public async Task GetTranslations_FileDoesNotExist_ReturnsEmptyCacheResponse() var result = await _service.GetTranslations(language, false); // Assert - Assert.IsFalse(result.FoundInCache); + Assert.IsFalse(result.FoundTranslationsInCache); Assert.AreEqual(0, result.Result.Count); } @@ -171,7 +171,7 @@ public async Task GetTranslations_FileExists_ReturnsValidTranslations() var result = await _service.GetTranslations(language, false); // Assert - Assert.IsTrue(result.FoundInCache); + Assert.IsTrue(result.FoundTranslationsInCache); CollectionAssert.AreEquivalent(translations.ToList(), result.Result.ToList()); } @@ -496,7 +496,7 @@ public async Task GetTranslations_EmptyFile_ReturnsEmptyDictionary() var result = await _service.GetTranslations(language, false); // Assert - Assert.IsTrue(result.FoundInCache); + Assert.IsTrue(result.FoundTranslationsInCache); Assert.AreEqual(0, result.Result.Count); } @@ -644,7 +644,7 @@ public async Task StoreTranslationsAsync_ShouldNotThrow_WhenCalledConcurrently() // Verify file was written correctly var result = await service.GetTranslations("de", false); - result.FoundInCache.Should().BeTrue(); + result.FoundTranslationsInCache.Should().BeTrue(); result.Result.Count.Should().Be(2); } finally @@ -718,7 +718,7 @@ public async Task GetTranslations_ReadsNewtonsoftEraFile_Successfully() var result = await _service.GetTranslations(language, false); // Assert - result.FoundInCache.Should().BeTrue(); + result.FoundTranslationsInCache.Should().BeTrue(); result.Result.Should().HaveCount(2); result.Result["key1"].Should().Be("value1"); result.Result["key2"].Should().Be("value2"); @@ -747,5 +747,130 @@ public async Task GetLanguages_ReadsNewtonsoftEraFile_Successfully() } #endregion + + #region ETag sidecar + + [TestMethod] + public async Task StoreTranslationsAsync_WritesETagSidecar_WhenETagProvided() + { + // Arrange — sidecar lives next to the translations file with the same + // language-derived base name and a .etag extension. We must write the + // tag verbatim so it round-trips byte-for-byte into the next + // If-None-Match header. + const string etag = "\"abc123\""; + var translations = new Dictionary { { "key1", "value1" } }; + var language = "en"; + var jsonPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); + var etagPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.etag"); + _fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny())); + _fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask); + _fileSystem.Setup(fs => fs.WriteAllTextAsync(etagPath, etag)).Returns(Task.CompletedTask); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); + _fileSystem.Setup(fs => fs.FileExists(jsonPath)).Returns(false); + _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, jsonPath, null)); + + // Act + await _service.StoreTranslationsAsync(language, translations, etag); + + // Assert + _fileSystem.Verify(fs => fs.WriteAllTextAsync(etagPath, etag), Times.Once); + } + + [TestMethod] + public async Task StoreTranslationsAsync_DoesNotWriteSidecar_WhenETagIsNull() + { + // Arrange — server returned 200 but emitted no ETag header. We must + // not create an empty/garbage sidecar that would later be sent as a + // bogus If-None-Match. + var translations = new Dictionary { { "key1", "value1" } }; + var language = "en"; + var jsonPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); + var etagPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.etag"); + _fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny())); + _fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); + _fileSystem.Setup(fs => fs.FileExists(jsonPath)).Returns(false); + _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, jsonPath, null)); + + // Act + await _service.StoreTranslationsAsync(language, translations, null); + + // Assert + _fileSystem.Verify(fs => fs.WriteAllTextAsync(etagPath, It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslations_ReadsETagFromSidecar_WhenSidecarExists() + { + // Arrange — translations file and sidecar both present. The cache + // response must surface both so the caller can revalidate. + const string etag = "\"deadbeef\""; + var language = "en"; + var jsonPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + var etagPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.etag"); + var translations = new Dictionary { { "key1", "value1" } }; + _fileSystem.Setup(fs => fs.FileExists(jsonPath)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(jsonPath)).ReturnsAsync(JsonSerializer.Serialize(translations)); + _fileSystem.Setup(fs => fs.FileExists(etagPath)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(etagPath)).ReturnsAsync(etag); + + // Act + var result = await _service.GetTranslations(language, false); + + // Assert + result.FoundTranslationsInCache.Should().BeTrue(); + result.ETag.Should().Be(etag); + } + + [TestMethod] + public async Task GetTranslations_ReturnsCacheMiss_WhenJsonMissingButETagSidecarPresent() + { + // Arrange — orphan sidecar: the .etag file exists on disk but its + // companion .json does not. Can happen after a partial write, + // tampered cache dir, or a crash mid-store. The cache must report + // a clean miss (no exception) so the higher layer falls through to + // a normal HTTP GET without trying to send a stale If-None-Match. + var language = "en"; + var jsonPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + var etagPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.etag"); + _fileSystem.Setup(fs => fs.FileExists(jsonPath)).Returns(false); + _fileSystem.Setup(fs => fs.FileExists(etagPath)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(etagPath)).ReturnsAsync("\"orphan\""); + + // Act + var result = await _service.GetTranslations(language, false); + + // Assert + result.FoundTranslationsInCache.Should().BeFalse(); + result.ETag.Should().BeNull(); + // Sidecar was never read (no point — without a body we can't safely revalidate). + _fileSystem.Verify(fs => fs.ReadAllTextAsync(etagPath), Times.Never); + } + + [TestMethod] + public async Task GetTranslations_ETagInLocalCacheIsNull_WhenSidecarMissing() + { + // Arrange — pre-ETag cache directory (older clients): translations + // file exists, sidecar does not. The cache must still return the + // translations and report ETag = null rather than erroring. + var language = "en"; + var jsonPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + var etagPath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.etag"); + var translations = new Dictionary { { "key1", "value1" } }; + _fileSystem.Setup(fs => fs.FileExists(jsonPath)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(jsonPath)).ReturnsAsync(JsonSerializer.Serialize(translations)); + _fileSystem.Setup(fs => fs.FileExists(etagPath)).Returns(false); + + // Act + var result = await _service.GetTranslations(language, false); + + // Assert + result.FoundTranslationsInCache.Should().BeTrue(); + result.ETag.Should().BeNull(); + } + + #endregion } } diff --git a/Calinga.NET.Tests/InMemoryCachingServiceTests.cs b/Calinga.NET.Tests/InMemoryCachingServiceTests.cs index 3372b78..ba71d81 100644 --- a/Calinga.NET.Tests/InMemoryCachingServiceTests.cs +++ b/Calinga.NET.Tests/InMemoryCachingServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -35,19 +35,44 @@ public async Task GetTranslations_ShouldGetTranslations_WhenCached() } [TestMethod] - public async Task GetTranslations_ShouldClearCache_WhenCacheExpired() + public async Task GetTranslations_ShouldReturnStaleEntry_WhenCacheExpired() { - // Arrange + // Arrange — translations + ETag stored, then time advanced past expiry. + // The entry must remain readable so the caller can revalidate the + // server (If-None-Match) without re-downloading the body. var timeService = new Mock(); var sut = new InMemoryCachingService(timeService.Object, GetSettings(2)); - await sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De); + await sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, "\"abc\""); // Act timeService.Setup(t => t.GetCurrentDateTime()).Returns(DateTime.Now.AddSeconds(7)); var actual = await sut.GetTranslations(TestData.Language_DE, false); // Assert - actual.Result.Should().BeEquivalentTo(TestData.EmptyTranslations); + actual.FoundTranslationsInCache.Should().BeTrue(); + actual.IsStale.Should().BeTrue(); + actual.Result.Should().BeEquivalentTo(TestData.Translations_De); + actual.ETag.Should().Be("\"abc\""); + } + + [TestMethod] + public async Task StoreTranslationsAsync_AfterExpiry_FlipsIsStaleBackToFalse() + { + // Arrange — once a Store occurs (e.g. after a successful revalidation), + // the entry must be considered fresh again. + var timeService = new Mock(); + timeService.Setup(t => t.GetCurrentDateTime()).Returns(DateTime.Now); + var sut = new InMemoryCachingService(timeService.Object, GetSettings(2)); + await sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, "\"abc\""); + timeService.Setup(t => t.GetCurrentDateTime()).Returns(DateTime.Now.AddSeconds(7)); + (await sut.GetTranslations(TestData.Language_DE, false)).IsStale.Should().BeTrue(); + + // Act — fresh store at the new "now" + await sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, "\"abc\""); + + // Assert + var actual = await sut.GetTranslations(TestData.Language_DE, false); + actual.IsStale.Should().BeFalse(); } [TestMethod] @@ -139,6 +164,53 @@ public async Task ClearCache_ShouldClearAllItems() (await _sut.GetTranslations(TestData.Language_EN, false)).Result.Should().BeEquivalentTo(TestData.EmptyTranslations); } + [TestMethod] + public async Task StoreTranslationsAsync_PersistsETag_AlongsideTranslations() + { + // Arrange — ETag is the cache's contract with the server: store-then-read + // must round-trip the value so we can revalidate via If-None-Match. + const string etag = "\"abc123\""; + + // Act + await _sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, etag); + var actual = await _sut.GetTranslations(TestData.Language_DE, false); + + // Assert + actual.FoundTranslationsInCache.Should().BeTrue(); + actual.ETag.Should().Be(etag); + } + + [TestMethod] + public async Task GetTranslations_ETagInLocalCacheIsNull_WhenStoredWithoutETag() + { + // Arrange — back-compat: existing callers that store without an ETag + // (server didn't emit one, or older code path) must see ETag = null. + await _sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De); + + // Act + var actual = await _sut.GetTranslations(TestData.Language_DE, false); + + // Assert + actual.FoundTranslationsInCache.Should().BeTrue(); + actual.ETag.Should().BeNull(); + } + + [TestMethod] + public async Task StoreTranslationsAsync_OverwritesETag_OnSecondStore() + { + // Arrange — when fresh translations arrive (200 with new ETag), the + // stored ETag must be replaced. Otherwise we'd revalidate against + // stale content with a stale ETag. + await _sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, "\"old\""); + + // Act + await _sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De, "\"new\""); + var actual = await _sut.GetTranslations(TestData.Language_DE, false); + + // Assert + actual.ETag.Should().Be("\"new\""); + } + private CalingaServiceSettings GetSettings(uint? expiration = null) { return new CalingaServiceSettings { MemoryCacheExpirationIntervalInSeconds = expiration == null ? default : expiration.Value }; @@ -165,7 +237,7 @@ public async Task StoreTranslationsAsync_ShouldNotThrow_WhenSameLanguageStoredCo // Verify data is correctly stored var result = await sut.GetTranslations(TestData.Language_DE, false); - result.FoundInCache.Should().BeTrue(); + result.FoundTranslationsInCache.Should().BeTrue(); result.Result.Should().BeEquivalentTo(TestData.Translations_De); } @@ -189,7 +261,7 @@ public async Task StoreTranslationsAsync_ShouldNotThrow_WhenDifferentLanguagesSt foreach (var lang in languages) { var result = await sut.GetTranslations(lang, false); - result.FoundInCache.Should().BeTrue($"language {lang} should be cached"); + result.FoundTranslationsInCache.Should().BeTrue($"language {lang} should be cached"); } } @@ -292,4 +364,4 @@ public async Task ClearCache_ShouldNotThrow_WhenCalledConcurrentlyWithReadsAndWr #endregion } -} \ No newline at end of file +} diff --git a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs index b7d829f..9ca14e2 100644 --- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs +++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; using System.Threading.Tasks; using Calinga.NET.Caching; using Calinga.NET.Infrastructure; @@ -203,7 +205,103 @@ public async Task GetTranslationsAsync_OnNullJsonBody_ReturnsEmptyDictionary() // Assert result.Should().NotBeNull(); - result.Should().BeEmpty(); + result.Translations.Should().BeEmpty(); + } + + [TestMethod] + public async Task GetTranslationsAsync_SendsIfNoneMatch_WhenCachedETagProvided() + { + // Arrange — when the caller supplies a cached ETag, ConsumerHttpClient must + // place it in the outgoing If-None-Match header so the server can answer 304. + const string cachedETag = "\"abc123\""; + HttpRequestMessage? capturedRequest = null; + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Get, "*") + .With(req => { capturedRequest = req; return true; }) + .Respond("application/json", "{}"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + await sut.GetTranslationsAsync("de", cachedETag).ConfigureAwait(false); + + // Assert + capturedRequest.Should().NotBeNull(); + var ifNoneMatchTags = capturedRequest!.Headers.IfNoneMatch.ToList(); + ifNoneMatchTags.Should().HaveCount(1); + ifNoneMatchTags[0].Tag.Should().Be(cachedETag); + } + + [TestMethod] + public async Task GetTranslationsAsync_On304_ReturnsNotModifiedFlag_WithEmptyTranslations() + { + // Arrange — a 304 response means the cached body is still fresh. + // The client must surface NotModified=true rather than throwing + // TranslationsNotAvailableException (the legacy behaviour). + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Get, "*") + .Respond(HttpStatusCode.NotModified); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + var response = await sut.GetTranslationsAsync("de", "\"abc123\"").ConfigureAwait(false); + + // Assert + response.Should().NotBeNull(); + response.NotModified.Should().BeTrue(); + response.Translations.Should().BeEmpty(); + response.ETag.Should().Be("\"abc123\""); + } + + [TestMethod] + public async Task GetTranslationsAsync_On200_ReturnsETagFromResponseHeader() + { + // Arrange — fresh 200 response carries an ETag header. The client must + // surface that ETag so the caching layer can store it for next time. + const string serverETag = "\"deadbeef\""; + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Get, "*") + .Respond(_ => + { + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"k1\":\"v1\"}", Encoding.UTF8, "application/json") + }; + resp.Headers.ETag = new EntityTagHeaderValue(serverETag, true); + return resp; + }); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + var response = await sut.GetTranslationsAsync("de").ConfigureAwait(false); + + // Assert + response.NotModified.Should().BeFalse(); + response.ETag.Should().Be(serverETag); + response.Translations.Should().BeEquivalentTo(new Dictionary { { "k1", "v1" } }); + } + + [TestMethod] + public async Task GetTranslationsAsync_On200WithoutETagHeader_ReturnsNullETag() + { + // Arrange — server is reachable but did not emit an ETag (e.g. cache + // bypass, proxy stripping). The client must not invent one; downstream + // logic relies on null to mean "no ETag known". + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Get, "*") + .Respond("application/json", "{\"k1\":\"v1\"}"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + var response = await sut.GetTranslationsAsync("de").ConfigureAwait(false); + + // Assert + response.NotModified.Should().BeFalse(); + response.ETag.Should().BeNull(); + response.Translations.Should().BeEquivalentTo(new Dictionary { { "k1", "v1" } }); } private static CalingaServiceSettings CreateSettings(bool isDevMode = false) diff --git a/Calinga.NET.Tests/TestData.cs b/Calinga.NET.Tests/TestData.cs index 5940778..71ba0c9 100644 --- a/Calinga.NET.Tests/TestData.cs +++ b/Calinga.NET.Tests/TestData.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using Calinga.NET.Caching; +using Calinga.NET.Infrastructure; using static System.FormattableString; namespace Calinga.NET.Tests @@ -22,6 +23,8 @@ internal static class TestData internal static IReadOnlyDictionary Translations_En => CreateTranslations(Language_EN); + internal static TranslationsHttpResponse Http_Translations_De => new TranslationsHttpResponse(Translations_De, null, false); + internal static IReadOnlyDictionary EmptyTranslations => new ReadOnlyDictionary(new Dictionary()); diff --git a/Calinga.NET/Caching/CacheResponse.cs b/Calinga.NET/Caching/CacheResponse.cs index afda5bf..cccfde3 100644 --- a/Calinga.NET/Caching/CacheResponse.cs +++ b/Calinga.NET/Caching/CacheResponse.cs @@ -5,15 +5,25 @@ namespace Calinga.NET.Caching { public class CacheResponse { - public CacheResponse(IReadOnlyDictionary result, bool foundInCache) + public CacheResponse(IReadOnlyDictionary result, bool foundTranslationsInCache, string? etag = null, bool isStale = false) { Result = result; - FoundInCache = foundInCache; + FoundTranslationsInCache = foundTranslationsInCache; + ETag = etag; + IsStale = isStale; } public IReadOnlyDictionary Result { get; } - public bool FoundInCache { get; } + public bool FoundTranslationsInCache { get; } + + public string? ETag { get; } + + /// + /// True when the entry was found but its in-memory expiration has elapsed. + /// Data and ETag remain readable so the caller can revalidate via If-None-Match. + /// + public bool IsStale { get; } public static CacheResponse Empty => new CacheResponse(new ReadOnlyDictionary(new Dictionary()), false); } diff --git a/Calinga.NET/Caching/CascadedCachingService.cs b/Calinga.NET/Caching/CascadedCachingService.cs index 54f19cc..57abd7e 100644 --- a/Calinga.NET/Caching/CascadedCachingService.cs +++ b/Calinga.NET/Caching/CascadedCachingService.cs @@ -21,12 +21,12 @@ public async Task GetTranslations(string language, bool includeDr { var cacheResponse = await cachingService.GetTranslations(language, includeDrafts); - if (cacheResponse.FoundInCache) + if (cacheResponse.FoundTranslationsInCache) { // Backfill earlier caches that missed foreach (var missedCache in missedCaches) { - await missedCache.StoreTranslationsAsync(language, cacheResponse.Result); + await missedCache.StoreTranslationsAsync(language, cacheResponse.Result, cacheResponse.ETag); } return cacheResponse; } @@ -66,9 +66,12 @@ public Task StoreLanguagesAsync(IEnumerable languageList) return Task.WhenAll(tasks.ToArray()); } - public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) + public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) => + StoreTranslationsAsync(language, translations, null); + + public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations, string? etag) { - var tasks = _cachingServices.Select(x => x.StoreTranslationsAsync(language, translations)); + var tasks = _cachingServices.Select(x => x.StoreTranslationsAsync(language, translations, etag)); return Task.WhenAll(tasks.ToArray()); } diff --git a/Calinga.NET/Caching/FileCachingService.cs b/Calinga.NET/Caching/FileCachingService.cs index e2d5679..8a34b49 100644 --- a/Calinga.NET/Caching/FileCachingService.cs +++ b/Calinga.NET/Caching/FileCachingService.cs @@ -42,8 +42,9 @@ public async Task GetTranslations(string languageName, bool inclu var dict = string.IsNullOrWhiteSpace(fileContent) ? new Dictionary() : JsonSerializer.Deserialize>(fileContent) ?? new Dictionary(); - - return new CacheResponse(dict, true); + + var etag = await TryReadETagAsync(languageName).ConfigureAwait(false); + return new CacheResponse(dict, true, etag); } catch (IOException ex) { @@ -105,7 +106,10 @@ public async Task ClearCache() // Creates a temporary file to store the translations and validates the JSON content. // If a previous version of the file exists, it is renamed before replacing it with the new file. // Logs warnings if JSON is invalid or if an IOException occurs. - public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) + public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) => + StoreTranslationsAsync(language, translations, null); + + public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations, string? etag) { if (_settings.DoNotWriteCacheFiles) return; @@ -136,6 +140,8 @@ public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary TryReadETagAsync(string language) + { + var etagPath = Path.Combine(_filePath, GetETagFileName(language)); + if (!_fileSystem.FileExists(etagPath)) + return null; + + try + { + var content = await _fileSystem.ReadAllTextAsync(etagPath).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(content) ? null : content; + } + catch (IOException) + { + return null; + } + } + public async Task StoreLanguagesAsync(IEnumerable languageList) { if (_settings.DoNotWriteCacheFiles) @@ -203,13 +244,21 @@ public async Task StoreLanguagesAsync(IEnumerable languageList) } private static string GetFileName(string language) + { + return Invariant($"{SanitizeLanguage(language)}.json"); + } + + private static string GetETagFileName(string language) + { + return Invariant($"{SanitizeLanguage(language)}.etag"); + } + + private static string SanitizeLanguage(string language) { if (language.Contains("..") || Path.IsPathRooted(language)) throw new ArgumentException("Invalid language name or path: " + language); - - var sanitizedLanguage = System.Text.RegularExpressions.Regex.Replace(language, @"[^a-zA-Z0-9_\-~]", "").ToUpper(); - return Invariant($"{sanitizedLanguage}.json"); + return System.Text.RegularExpressions.Regex.Replace(language, @"[^a-zA-Z0-9_\-~]", "").ToUpper(); } private async Task DeleteDirectoryRecursivelyAsync(DirectoryInfo directory) diff --git a/Calinga.NET/Caching/ICachingService.cs b/Calinga.NET/Caching/ICachingService.cs index b7a3a07..2a3b0e7 100644 --- a/Calinga.NET/Caching/ICachingService.cs +++ b/Calinga.NET/Caching/ICachingService.cs @@ -13,6 +13,8 @@ public interface ICachingService Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations); + Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations, string? etag); + Task ClearCache(); } } \ No newline at end of file diff --git a/Calinga.NET/Caching/InMemoryCachingService.cs b/Calinga.NET/Caching/InMemoryCachingService.cs index f5951fa..f062550 100644 --- a/Calinga.NET/Caching/InMemoryCachingService.cs +++ b/Calinga.NET/Caching/InMemoryCachingService.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Calinga.NET.Infrastructure; @@ -19,6 +18,7 @@ public class InMemoryCachingService : ICachingService private DateTime _expirationDate; private volatile IReadOnlyList _languagesList; private ConcurrentDictionary> _translations; + private ConcurrentDictionary _etags; public InMemoryCachingService(IDateTimeService timeService, CalingaServiceSettings settings) { @@ -27,27 +27,28 @@ public InMemoryCachingService(IDateTimeService timeService, CalingaServiceSettin _expirationDate = GetExpirationDate(_memoryCacheExpirationIntervalInSeconds); _withExpirationDate = _expirationDate != DateTime.MaxValue; _translations = new ConcurrentDictionary>(); + _etags = new ConcurrentDictionary(); _languagesList = new List(); } public Task GetTranslations(string language, bool includeDrafts) { - if (_withExpirationDate && IsCacheExpired()) + if (!_translations.TryGetValue(language, out var translations)) { - ClearCacheInternal(); return Task.FromResult(CacheResponse.Empty); } - return Task.FromResult(_translations.TryGetValue(language, out var translations) - ? new CacheResponse(translations, true) - : CacheResponse.Empty); + var etag = _etags.TryGetValue(language, out var storedEtag) ? storedEtag : null; + // On expiry we preserve the entry so callers can revalidate via If-None-Match. + // The next StoreTranslationsAsync resets _expirationDate, flipping IsStale back to false. + var isStale = _withExpirationDate && IsCacheExpired(); + return Task.FromResult(new CacheResponse(translations, true, etag, isStale)); } public Task GetLanguages() { if (_withExpirationDate && IsCacheExpired()) { - ClearCacheInternal(); return Task.FromResult(CachedLanguageListResponse.Empty); } @@ -68,6 +69,7 @@ private void ClearCacheInternal() lock (_lock) { _translations = new ConcurrentDictionary>(); + _etags = new ConcurrentDictionary(); _languagesList = new List(); _expirationDate = DateTime.MinValue; } @@ -84,9 +86,20 @@ public Task StoreLanguagesAsync(IEnumerable languageList) return Task.CompletedTask; } - public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) + public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations) => + StoreTranslationsAsync(language, translations, null); + + public Task StoreTranslationsAsync(string language, IReadOnlyDictionary translations, string? etag) { _translations[language] = translations; + if (etag == null) + { + _etags.TryRemove(language, out _); + } + else + { + _etags[language] = etag; + } lock (_lock) { _expirationDate = GetExpirationDate(_memoryCacheExpirationIntervalInSeconds); @@ -107,11 +120,6 @@ private bool IsCacheExpired() return _dateTimeService.GetCurrentDateTime() >= expiration; } - private static DateTime ConvertToDateTime(object? date) - { - return Convert.ToDateTime(date); - } - private DateTime GetExpirationDate(uint? expiration) { return expiration == null || expiration == 0 ? DateTime.MaxValue : _dateTimeService.GetCurrentDateTime().AddSeconds(expiration.Value); @@ -119,4 +127,4 @@ private DateTime GetExpirationDate(uint? expiration) #endregion Privat helper Methods } -} \ No newline at end of file +} diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 759aa28..16500f9 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -188,11 +188,20 @@ public async Task> GetTranslationsAsync(stri while (true) { - var translations = await TryGetFromCache(language, invalidateCache).ConfigureAwait(false); - if (translations != null) - return translations; - - translations = await TryGetFromApi(language).ConfigureAwait(false); + // Always read the cache: invalidateCache only suppresses the + // fast-path return. The cached ETag is still useful for + // If-None-Match revalidation, which lets the server answer 304 + // and save a full body transfer when nothing has changed. + var cacheResponse = await TryReadCache(language).ConfigureAwait(false); + + if (!invalidateCache && cacheResponse.FoundTranslationsInCache && !cacheResponse.IsStale) + { + _logger.Info($"Translations for language {language} fetched from cache"); + var fresh = cacheResponse.Result; + return _settings.IsDevMode ? fresh.ToDictionary(k => k.Key, k => k.Key) : fresh; + } + + var translations = await TryGetFromApi(language, cacheResponse).ConfigureAwait(false); if (translations != null) return translations; @@ -294,40 +303,54 @@ public async Task> GetTranslationsAsync(stri return subset; } - private async Task?> TryGetFromCache(string language, bool invalidateCache) + private async Task TryReadCache(string language) { - if (invalidateCache) - return null; - try { - var cacheResponse = await _cachingService.GetTranslations(language, _settings.IncludeDrafts).ConfigureAwait(false); - if (cacheResponse is { FoundInCache: true }) - { - _logger.Info($"Translations for language {language} fetched from cache"); - var result = cacheResponse.Result; - return _settings.IsDevMode ? result.ToDictionary(k => k.Key, k => k.Key) : result; - } + return await _cachingService.GetTranslations(language, _settings.IncludeDrafts).ConfigureAwait(false); } catch (Exception e) { _logger.Warn($"Error while fetching translations for language {language} from cache. Trying to fetch from consumer API. Error: {e.Message}"); + return CacheResponse.Empty; } - return null; } - - private async Task?> TryGetFromApi(string language) + + private async Task?> TryGetFromApi(string language, CacheResponse cacheResponse) { if (_settings.UseCacheOnly) + { + // No HTTP allowed — surface whatever cache holds (fresh or stale). + if (cacheResponse.FoundTranslationsInCache) + { + var cached = cacheResponse.Result; + return _settings.IsDevMode ? cached.ToDictionary(k => k.Key, k => k.Key) : cached; + } return null; - + } + + var ifNoneMatch = cacheResponse.FoundTranslationsInCache ? cacheResponse.ETag : null; + try { - var foundTranslations = await _consumerHttpClient.GetTranslationsAsync(language).ConfigureAwait(false); + var httpResponse = ifNoneMatch == null + ? await _consumerHttpClient.GetTranslationsAsync(language).ConfigureAwait(false) + : await _consumerHttpClient.GetTranslationsAsync(language, ifNoneMatch).ConfigureAwait(false); + + if (httpResponse.NotModified && cacheResponse.FoundTranslationsInCache) + { + _logger.Info($"Translations for language {language} unchanged (304); reusing cached entry and refreshing expiration"); + var etagToStore = cacheResponse.ETag ?? httpResponse.ETag; + await _cachingService.StoreTranslationsAsync(language, cacheResponse.Result, etagToStore).ConfigureAwait(false); + var reused = cacheResponse.Result; + return _settings.IsDevMode ? reused.ToDictionary(k => k.Key, k => k.Key) : reused; + } + + var foundTranslations = httpResponse.Translations; if (foundTranslations != null && foundTranslations.Any()) { _logger.Info($"Translations for language {language} fetched from consumer API"); - await _cachingService.StoreTranslationsAsync(language, foundTranslations).ConfigureAwait(false); + await _cachingService.StoreTranslationsAsync(language, foundTranslations, httpResponse.ETag).ConfigureAwait(false); return _settings.IsDevMode ? foundTranslations.ToDictionary(k => k.Key, k => k.Key) : foundTranslations; } } diff --git a/Calinga.NET/DateTimeService.cs b/Calinga.NET/DateTimeService.cs index 2087757..a234eb0 100644 --- a/Calinga.NET/DateTimeService.cs +++ b/Calinga.NET/DateTimeService.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Calinga.NET { diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs index d6148c2..86079b2 100644 --- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs @@ -44,13 +44,28 @@ private void AddClientVersionHeader() } } - public async Task> GetTranslationsAsync(string language) + public Task GetTranslationsAsync(string language) => GetTranslationsAsync(language, (string?)null); + + public async Task GetTranslationsAsync(string language, string? ifNoneMatch) { var queryParameter = _settings.IncludeDrafts ? Invariant($"?includeDrafts={_settings.IncludeDrafts}") : string.Empty; var url = Invariant( $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/{language}{queryParameter}"); - var response = await _httpClient.GetAsync(url).ConfigureAwait(false); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + if (!string.IsNullOrEmpty(ifNoneMatch)) + { + // TryAddWithoutValidation lets us echo the server's tag byte-for-byte, including + // any weak prefix or quoting — the server's filter compares strings literally. + request.Headers.TryAddWithoutValidation("If-None-Match", ifNoneMatch); + } + + var response = await _httpClient.SendAsync(request).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotModified) + { + return TranslationsHttpResponse.NotModifiedResponse(GetResponseETag(response) ?? ifNoneMatch); //We fall back to the etag we sent, when the server did not resend it + } switch (response.StatusCode) { @@ -68,7 +83,7 @@ public async Task> GetTranslationsAsync(stri var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - return CreateTranslationsDictionary(body); + return new TranslationsHttpResponse(CreateTranslationsDictionary(body), GetResponseETag(response), notModified: false); } public async Task> GetTranslationsAsync(string language, IEnumerable keys) @@ -132,6 +147,14 @@ private static Dictionary CreateTranslationsDictionary(string js ?? new Dictionary(); } + private static string? GetResponseETag(HttpResponseMessage response) + { + // Use .Tag (just the quoted opaque value) and drop any weak prefix — + // the server compares If-None-Match using the same .Tag string, so + // weak vs strong never enters the equality check. + return response.Headers.ETag?.Tag; + } + private static IEnumerable DeserializeLanguages(string json) { using var doc = JsonDocument.Parse(json); diff --git a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs index d87ab4a..2cb8632 100644 --- a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs @@ -6,7 +6,9 @@ namespace Calinga.NET.Infrastructure { public interface IConsumerHttpClient { - Task> GetTranslationsAsync(string language); + Task GetTranslationsAsync(string language); + + Task GetTranslationsAsync(string language, string? ifNoneMatch); Task> GetTranslationsAsync(string language, IEnumerable keys); diff --git a/Calinga.NET/Infrastructure/TranslationsHttpResponse.cs b/Calinga.NET/Infrastructure/TranslationsHttpResponse.cs new file mode 100644 index 0000000..767d619 --- /dev/null +++ b/Calinga.NET/Infrastructure/TranslationsHttpResponse.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Calinga.NET.Infrastructure +{ + public sealed class TranslationsHttpResponse + { + public TranslationsHttpResponse(IReadOnlyDictionary translations, string? etag, bool notModified) + { + Translations = translations; + ETag = etag; + NotModified = notModified; + } + + public IReadOnlyDictionary Translations { get; } + + public string? ETag { get; } + + public bool NotModified { get; } + + public static TranslationsHttpResponse NotModifiedResponse(string? etag) => + new TranslationsHttpResponse(EmptyTranslations, etag, true); + + private static readonly IReadOnlyDictionary EmptyTranslations = + new ReadOnlyDictionary(new Dictionary()); + } +}