From 617cb891c02e0df779f0a49bef2f434e7cd26d85 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Fri, 24 Apr 2026 12:36:24 +0200 Subject: [PATCH 01/23] Introduce the new endpoint to the Calinga.NET package --- Calinga.NET.Tests/Calinga.NET.Tests.csproj | 10 +- Calinga.NET.Tests/CalingaServiceTests.cs | 194 ++++++++++++++++++ .../Infrastructure/ConsumerHttpClientTest.cs | 121 +++++++++++ Calinga.NET/Calinga.NET.csproj | 18 +- Calinga.NET/CalingaService.cs | 45 +++- Calinga.NET/ICalingaService.cs | 1 + .../Infrastructure/ConsumerHttpClient.cs | 30 +++ .../Infrastructure/IConsumerHttpClient.cs | 2 + README.md | 26 +++ 9 files changed, 430 insertions(+), 17 deletions(-) diff --git a/Calinga.NET.Tests/Calinga.NET.Tests.csproj b/Calinga.NET.Tests/Calinga.NET.Tests.csproj index b32ace7..88f118b 100644 --- a/Calinga.NET.Tests/Calinga.NET.Tests.csproj +++ b/Calinga.NET.Tests/Calinga.NET.Tests.csproj @@ -1,7 +1,7 @@  - netcoreapp6.0 + net9.0 false 8.0 @@ -18,9 +18,9 @@ - - - + + + @@ -29,7 +29,7 @@ - + diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index 0b1a49d..02fac27 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -523,5 +523,199 @@ await act.Should().ThrowAsync() _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); } + + #region Keyed GetTranslationsAsync + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_WarmCache_StillPostsToServer() + { + // Arrange — warm cache must not rescue the call; keyed requests always go to the server. + var serverSubset = new Dictionary { { TestData.Key_1, "server value for key 1" } }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); + + // Assert + result.Should().BeEquivalentTo(serverSubset); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()), Times.Once); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_MissingFromServer_Omitted() + { + // Arrange — server omits Key_2 from its response; client surfaces that as a missing entry. + var serverSubset = new Dictionary + { + { TestData.Key_1, "from server 1" } + // Key_2 intentionally omitted. + }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 }); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(TestData.Key_1); + result.Should().NotContainKey(TestData.Key_2); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_ColdCache_CallsKeyedHttp_NotStored() + { + // Arrange + var serverSubset = new Dictionary { { TestData.Key_1, "server value for key 1" } }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); + + // Assert + result.Should().BeEquivalentTo(serverSubset); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()), Times.Once); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_ColdCache_ReturnsServerSubset() + { + // Arrange + var serverSubset = new Dictionary + { + { TestData.Key_1, "from server 1" } + }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 }); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(TestData.Key_1); + result.Should().NotContainKey(TestData.Key_2); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_ThrowsLanguagesNotAvailable() + { + // Arrange + var settings = CreateSettings(); + settings.UseCacheOnly = true; + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); + + // Assert + await act.Should().ThrowAsync(); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _consumerHttpClient.Verify(x => x.GetLanguagesAsync(), Times.Never); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_ThrowsLanguagesNotAvailable() + { + // Arrange — warm cache must not rescue the call under UseCacheOnly; keyed calls never touch the cache. + var settings = CreateSettings(); + settings.UseCacheOnly = true; + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); + + // Assert + await act.Should().ThrowAsync(); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_NullKeys_ThrowsArgumentNullException() + { + // Arrange + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, (IEnumerable)null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_ReturnsEmpty_NoHttp_NoCacheAccess() + { + // Arrange + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, Array.Empty()); + + // Assert + result.Should().BeEmpty(); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_UseCacheOnly_ThrowsLanguagesNotAvailable() + { + // Arrange — UseCacheOnly is incompatible with the keyed overload regardless of whether the key + // collection is empty. The UseCacheOnly check runs before the empty-keys short-circuit. + var settings = CreateSettings(); + settings.UseCacheOnly = true; + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, Array.Empty()); + + // Assert + await act.Should().ThrowAsync(); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); + _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); + _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_IsDevMode_EchoesKeys() + { + // Arrange — DevMode echoes the keys returned by the server as their own values. + var settings = CreateSettings(isDevMode: true); + var serverSubset = new Dictionary { { TestData.Key_1, "some translation" } }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); + + // Assert + result.Should().ContainKey(TestData.Key_1).WhoseValue.Should().Be(TestData.Key_1); + } + + #endregion Keyed GetTranslationsAsync } } diff --git a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs index e807b3b..98f368a 100644 --- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs +++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs @@ -1,11 +1,15 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using Calinga.NET.Caching; using Calinga.NET.Infrastructure; +using Calinga.NET.Infrastructure.Exceptions; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; using RichardSzalay.MockHttp; namespace Calinga.NET.Tests.Infrastructure @@ -45,6 +49,123 @@ public async Task GetLanguages_ShouldReturnLanguageList_WhenResponseContainsVali }); } + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_UsesPost_ToV3LanguagesUrl() + { + // Arrange + var expectedUrl = $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/de"; + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .Expect(HttpMethod.Post, expectedUrl) + .Respond("application/json", "{}"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + mockMessageHandler.VerifyNoOutstandingExpectation(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_SendsJsonBody_WithKeyNames() + { + // Arrange + var expectedUrl = $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/de"; + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .Expect(HttpMethod.Post, expectedUrl) + .With(request => + { + if (request.Content == null) return false; + if (request.Content.Headers.ContentType?.MediaType != "application/json") return false; + var body = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var parsed = JObject.Parse(body); + var keyNames = parsed["keyNames"]?.ToObject>(); + return keyNames != null && keyNames.SequenceEqual(new[] { "k1", "k2" }); + }) + .Respond("application/json", "{}"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + await sut.GetTranslationsAsync("de", new[] { "k1", "k2" }).ConfigureAwait(false); + + // Assert + mockMessageHandler.VerifyNoOutstandingExpectation(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_IncludeDrafts_AddsQueryString() + { + // Arrange + var settings = CreateSettings(); + settings.IncludeDrafts = true; + var expectedUrl = + $"{settings.ConsumerApiBaseUrl}/{settings.Organization}/{settings.Team}/{settings.Project}/languages/de?includeDrafts=True"; + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .Expect(HttpMethod.Post, expectedUrl) + .Respond("application/json", "{}"); + var sut = new ConsumerHttpClient(settings, new HttpClient(mockMessageHandler)); + + // Act + await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + mockMessageHandler.VerifyNoOutstandingExpectation(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_On404_ThrowsTranslationsNotFound() + { + // Arrange + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Post, "*") + .Respond(HttpStatusCode.NotFound); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_On401_ThrowsAuthorizationFailed() + { + // Arrange + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Post, "*") + .Respond(HttpStatusCode.Unauthorized); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_On500_ThrowsTranslationsNotAvailable() + { + // Arrange + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Post, "*") + .Respond(HttpStatusCode.InternalServerError); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + await act.Should().ThrowAsync(); + } + private static CalingaServiceSettings CreateSettings(bool isDevMode = false) { return new CalingaServiceSettings diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index a3e238c..bba9232 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -6,18 +6,14 @@ enable Calinga.NET Library to integrate Calinga in .NET projects - 2.1.4 + 2.2.0 -## Bug Fixes -- Fixed cache backfill: In-memory cache is now repopulated when data is retrieved from file cache after expiration -- Fixed thread safety in InMemoryCachingService: Now uses ConcurrentDictionary and proper locking for multi-threaded environments -- Fixed missing locking in FileCachingService.StoreLanguagesAsync: Concurrent writes no longer corrupt the languages cache file -- Fixed file sharing: ReadAllTextAsync now allows concurrent reads (FileShare.Read) -- Fixed ClearCache: Now properly clears both translations and languages from in-memory cache - -## Improvements -- Resolved all nullable reference type warnings (CS8618) -- Added comprehensive thread-safety tests for caching services +## New Features +- Added `GetTranslationsAsync(string language, IEnumerable<string> keys)` to fetch a subset of translations for a given language without downloading the full dictionary. +- Every keyed call issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. +- Keys absent from the server response are silently omitted from the result. +- `UseCacheOnly = true` is incompatible with the keyed overload and throws `LanguagesNotAvailableException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache. +- When `UseCacheOnly` is false, passing an empty key collection returns an empty dictionary immediately (no HTTP call, no cache access). diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 785f0ac..0bae2cc 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -207,7 +207,50 @@ public async Task> GetTranslationsAsync(stri { return await GetTranslationsAsync(language, false); } - + + /// + /// Gets the subset of translations for the specified keys by issuing a POST to the Consumer API. + /// The cache is never consulted and never written — every call returns server-fresh data. + /// + /// The current state of never using the cache and always using server-fresh data is currently in testing and + /// can be subject to change. + /// + /// Keys absent from the server response are silently omitted. + /// + /// The language code. + /// The translation keys to fetch. + /// A dictionary containing only the requested keys that were found on the server. + /// Thrown when is null. + /// + /// Thrown when is true. Keyed calls always + /// require HTTP; they cannot be served from the cache, so this setting is incompatible with + /// the keyed overload regardless of whether the key collection is empty or not. + /// + public async Task> GetTranslationsAsync(string language, IEnumerable keys) + { + Guard.IsNotNullOrWhiteSpace(language); + if (keys == null) throw new ArgumentNullException(nameof(keys)); + + if (_settings.UseCacheOnly) + { + throw new LanguagesNotAvailableException( + $"Keyed translations are not served from the cache; cannot be fetched while UseCacheOnly is true. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); + } + + var keySet = new HashSet(keys, StringComparer.Ordinal); + + if (keySet.Count == 0) + { + return new Dictionary(); + } + + _logger.Info($"Fetching filtered translations for language {language} ({keySet.Count} key(s)) from consumer API"); + var subset = await _consumerHttpClient.GetTranslationsAsync(language, keySet).ConfigureAwait(false); + return _settings.IsDevMode + ? subset.ToDictionary(k => k.Key, k => k.Key) + : subset; + } + private async Task?> TryGetFromCache(string language, bool invalidateCache) { if (invalidateCache) diff --git a/Calinga.NET/ICalingaService.cs b/Calinga.NET/ICalingaService.cs index a7b06d1..ce146b7 100644 --- a/Calinga.NET/ICalingaService.cs +++ b/Calinga.NET/ICalingaService.cs @@ -9,6 +9,7 @@ public interface ICalingaService Task> GetTranslationsAsync(string language); Task> GetTranslationsAsync(string language, bool invalidateCache); + Task> GetTranslationsAsync(string language, IEnumerable keys); Task> GetLanguagesAsync(); diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs index d382243..588bfa9 100644 --- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs @@ -72,6 +72,36 @@ public async Task> GetTranslationsAsync(stri return CreateTranslationsDictionary(body); } + public async Task> GetTranslationsAsync(string language, IEnumerable keys) + { + 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 requestBody = JsonConvert.SerializeObject(new { keyNames = keys }); + using var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(url, content).ConfigureAwait(false); + + switch (response.StatusCode) + { + case HttpStatusCode.Unauthorized: + throw new AuthorizationFailedException(); + case HttpStatusCode.NotFound: + throw new TranslationsNotFoundException( + $"Translations not found for Organization = '{_settings.Organization}', Team = '{_settings.Team}', Project = '{_settings.Project}', Language = '{language}'"); + } + + if (!response.IsSuccessStatusCode) + { + throw new TranslationsNotAvailableException("Failed to fetch filtered translations"); + } + + var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + return CreateTranslationsDictionary(responseBody); + } + public async Task> GetLanguagesAsync() { try diff --git a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs index fffe9a2..d87ab4a 100644 --- a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs @@ -8,6 +8,8 @@ public interface IConsumerHttpClient { Task> GetTranslationsAsync(string language); + Task> GetTranslationsAsync(string language, IEnumerable keys); + Task> GetLanguagesAsync(); } } \ No newline at end of file diff --git a/README.md b/README.md index b1c0b6a..382c6da 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,29 @@ To fetch translations for languages with language tag you must provide the langu e.g. `de-AT~Intranet`. Calls to `GetLanguagesAsync()` will also return languages in this format. + +## Fetching a subset of keys + +When you only need a few translation keys and do not want to pay the cost of downloading the full language dictionary, use the overload that accepts a collection of key names: + +```csharp +var keys = new[] { "dashboard.title", "dashboard.subtitle" }; +var translations = await calingaService.GetTranslationsAsync("de", keys); +``` + +- Every keyed call **POSTs** to the Consumer API with a JSON body `{ "keyNames": [...] }` and returns only the translations the server responded with. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. +- Keys absent from the server response are silently omitted from the result (no exception). +- Passing `keys: null` throws `ArgumentNullException`. +- `UseCacheOnly = true` is incompatible with the keyed overload — passing any key collection (including an empty one) while `UseCacheOnly` is set throws `LanguagesNotAvailableException`, because keyed calls always require HTTP and cannot be served from the cache. +- When `UseCacheOnly` is false, passing an empty collection returns an empty dictionary immediately — no HTTP call, no cache access. + +### Transport summary + +| Call | HTTP method | Path | Cache read | Cache write | +|------|-------------|------|------------|-------------| +| `GetTranslationsAsync(language)` | `GET` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` | Yes | Full dictionary stored | +| `GetTranslationsAsync(language, keys)` with non-empty keys (and `UseCacheOnly = false`) | `POST` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` with body `{ "keyNames": [...] }` | No | Not stored | +| `GetTranslationsAsync(language, keys)` with empty keys (and `UseCacheOnly = false`) | none | — | No | — | +| `GetTranslationsAsync(language, keys)` with any keys while `UseCacheOnly = true` | — | — | — | Throws `LanguagesNotAvailableException` | + +Both calls share the existing `ConsumerApiBaseUrl` setting — no additional URL configuration is required. From 2af141a264befaac25a0fae5c84954f585124314 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Fri, 24 Apr 2026 12:47:57 +0200 Subject: [PATCH 02/23] revert changes to .net version --- Calinga.NET.Tests/Calinga.NET.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Calinga.NET.Tests/Calinga.NET.Tests.csproj b/Calinga.NET.Tests/Calinga.NET.Tests.csproj index 88f118b..c857592 100644 --- a/Calinga.NET.Tests/Calinga.NET.Tests.csproj +++ b/Calinga.NET.Tests/Calinga.NET.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + netcoreapp6.0 false 8.0 From 99b26c34a1c6341928ffaa44f07de5e221752648 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Fri, 24 Apr 2026 13:48:43 +0200 Subject: [PATCH 03/23] Remove Newtonsoft --- Calinga.NET/Caching/FileCachingService.cs | 14 +++++----- Calinga.NET/Calinga.NET.csproj | 2 +- .../Infrastructure/ConsumerHttpClient.cs | 27 +++++++++---------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/Calinga.NET/Caching/FileCachingService.cs b/Calinga.NET/Caching/FileCachingService.cs index f8adedd..e2d5679 100644 --- a/Calinga.NET/Caching/FileCachingService.cs +++ b/Calinga.NET/Caching/FileCachingService.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Calinga.NET.Infrastructure; using Calinga.NET.Infrastructure.Exceptions; -using Newtonsoft.Json; +using System.Text.Json; using static System.FormattableString; namespace Calinga.NET.Caching @@ -41,7 +41,7 @@ public async Task GetTranslations(string languageName, bool inclu var fileContent = await _fileSystem.ReadAllTextAsync(path).ConfigureAwait(false); var dict = string.IsNullOrWhiteSpace(fileContent) ? new Dictionary() - : JsonConvert.DeserializeObject>(fileContent) ?? new Dictionary(); + : JsonSerializer.Deserialize>(fileContent) ?? new Dictionary(); return new CacheResponse(dict, true); } @@ -66,7 +66,7 @@ public async Task GetLanguages() var fileContent = await _fileSystem.ReadAllTextAsync(path).ConfigureAwait(false); var list = string.IsNullOrWhiteSpace(fileContent) ? new List() - : JsonConvert.DeserializeObject>(fileContent) ?? new List(); + : JsonSerializer.Deserialize>(fileContent) ?? new List(); return new CachedLanguageListResponse(list, true); } @@ -123,9 +123,9 @@ public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary>(tempFileContent); + JsonSerializer.Deserialize>(tempFileContent); if (_fileSystem.FileExists(path)) { @@ -172,9 +172,9 @@ public async Task StoreLanguagesAsync(IEnumerable languageList) _fileSystem.CreateDirectory(_filePath); try { - await _fileSystem.WriteAllTextAsync(tempFilePath, JsonConvert.SerializeObject(languageList)).ConfigureAwait(false); + await _fileSystem.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(languageList)).ConfigureAwait(false); var tempFileContent = await _fileSystem.ReadAllTextAsync(tempFilePath).ConfigureAwait(false); - JsonConvert.DeserializeObject>(tempFileContent); + JsonSerializer.Deserialize>(tempFileContent); if (_fileSystem.FileExists(path)) { diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index bba9232..202e2f4 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -20,7 +20,7 @@ - + diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs index 588bfa9..cfc9835 100644 --- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Calinga.NET.Caching; using Calinga.NET.Infrastructure.Exceptions; -using Newtonsoft.Json; +using System.Text.Json; using static System.FormattableString; namespace Calinga.NET.Infrastructure @@ -78,7 +78,7 @@ public async Task> GetTranslationsAsync(stri var url = Invariant( $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/{language}{queryParameter}"); - var requestBody = JsonConvert.SerializeObject(new { keyNames = keys }); + var requestBody = JsonSerializer.Serialize(new { keyNames = keys }); using var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync(url, content).ConfigureAwait(false); @@ -129,23 +129,22 @@ private async Task GetResponseBody(string url) private static Dictionary CreateTranslationsDictionary(string json) { - return JsonConvert.DeserializeObject>(json)!; + return JsonSerializer.Deserialize>(json)!; } private static IEnumerable DeserializeLanguages(string json) { - return JsonConvert.DeserializeObject>>(json)! - .Select(l => + using var doc = JsonDocument.Parse(json); + return doc.RootElement.EnumerateArray().Select(l => + { + var languageTag = l.GetProperty("tag").GetString(); + var languageName = l.GetProperty("name").GetString(); + return new Language { - var languageTag = l["tag"]; - var isRefernece = l["isReference"]; - - return new Language - { - Name = string.IsNullOrEmpty(languageTag) ? l["name"] : $"{l["name"]}~{languageTag}", - IsReference = Convert.ToBoolean(isRefernece) - }; - }); + Name = string.IsNullOrEmpty(languageTag) ? languageName! : $"{languageName}~{languageTag}", + IsReference = l.GetProperty("isReference").GetBoolean() + }; + }).ToList(); } private void EnsureApiTokenHeaderIsSet() From b57ea6d77aa3e408ea67e800882502d94ffa041a Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Mon, 27 Apr 2026 15:46:08 +0200 Subject: [PATCH 04/23] Remove remaining Newtonsoft dependencies --- Calinga.NET.Tests/Context/TestContext.cs | 4 ++-- Calinga.NET.Tests/FileCachingServiceTests.cs | 24 +++++++++---------- .../Infrastructure/ConsumerHttpClientTest.cs | 11 +++++---- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Calinga.NET.Tests/Context/TestContext.cs b/Calinga.NET.Tests/Context/TestContext.cs index 7e86532..00827c2 100644 --- a/Calinga.NET.Tests/Context/TestContext.cs +++ b/Calinga.NET.Tests/Context/TestContext.cs @@ -9,7 +9,7 @@ using Moq; using Moq.Protected; -using Newtonsoft.Json; +using System.Text.Json; using Calinga.NET.Caching; using Calinga.NET.Infrastructure; @@ -120,7 +120,7 @@ private HttpClient BuildHttpClientMock() return new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonConvert.SerializeObject(translations)) + Content = new StringContent(JsonSerializer.Serialize(translations)) }; } catch (Exception) diff --git a/Calinga.NET.Tests/FileCachingServiceTests.cs b/Calinga.NET.Tests/FileCachingServiceTests.cs index 3acfc23..5d05aaf 100644 --- a/Calinga.NET.Tests/FileCachingServiceTests.cs +++ b/Calinga.NET.Tests/FileCachingServiceTests.cs @@ -10,7 +10,7 @@ using Calinga.NET.Infrastructure.Exceptions; using FluentAssertions; using Moq; -using Newtonsoft.Json; +using System.Text.Json; namespace Calinga.NET.Tests { @@ -48,7 +48,7 @@ public async Task StoreTranslationsAsync_CreatesFileWithValidJson() var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); _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(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)); @@ -105,7 +105,7 @@ public async Task StoreTranslationsAsync_OverwritesExistingFile() var prevFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.prev"); _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(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)); @@ -128,7 +128,7 @@ public async Task StoreTranslationsAsync_HandlesEmptyTranslations() var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); _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(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)); @@ -165,7 +165,7 @@ public async Task GetTranslations_FileExists_ReturnsValidTranslations() var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); var translations = new Dictionary { { "key1", "value1" } }; _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true); - _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonSerializer.Serialize(translations)); // Act var result = await _service.GetTranslations(language, false); @@ -210,7 +210,7 @@ public async Task GetLanguages_FileExists_ReturnsValidLanguages() var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json"); var languages = new List { new Language { Name = "en" } }; _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true); - _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonConvert.SerializeObject(languages)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonSerializer.Serialize(languages)); // Act var result = await _service.GetLanguages(); @@ -272,7 +272,7 @@ public async Task StoreLanguagesAsync_CreatesFileWithValidJson() "Languages.json.temp"); _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(JsonConvert.SerializeObject(languages)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(languages)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)); @@ -363,7 +363,7 @@ public async Task GetTranslations_InvalidJson_ThrowsExceptionAndLogsWarning() _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync("{invalid json}"); // Act & Assert - await Assert.ThrowsExceptionAsync(() => _service.GetTranslations(language, false)); + await Assert.ThrowsExceptionAsync(() => _service.GetTranslations(language, false)); } [TestMethod] @@ -375,7 +375,7 @@ public async Task GetLanguages_InvalidJson_ThrowsExceptionAndLogsWarning() _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync("{invalid json}"); // Act & Assert - await Assert.ThrowsExceptionAsync(() => _service.GetLanguages()); + await Assert.ThrowsExceptionAsync(() => _service.GetLanguages()); } [TestMethod] @@ -432,7 +432,7 @@ public async Task StoreTranslationsAsync_LogsInfoOnSuccess() var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); _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(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)); @@ -526,7 +526,7 @@ public async Task StoreTranslationsAsync_ReplaceFileThrowsIOException_DeletesTem var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp"); _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(JsonConvert.SerializeObject(translations)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)).Throws(); _fileSystem.Setup(fs => fs.FileExists(tempFilePath)).Returns(true); @@ -548,7 +548,7 @@ public async Task StoreLanguagesAsync_ReplaceFileThrowsIOException_DeletesTempFi var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json.temp"); _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(JsonConvert.SerializeObject(languages)); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(languages)); _fileSystem.Setup(fs => fs.FileExists(path)).Returns(false); _fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)).Throws(); _fileSystem.Setup(fs => fs.FileExists(tempFilePath)).Returns(true); diff --git a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs index 98f368a..68a06df 100644 --- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs +++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs @@ -9,7 +9,7 @@ using Calinga.NET.Infrastructure.Exceptions; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json.Linq; +using System.Text.Json; using RichardSzalay.MockHttp; namespace Calinga.NET.Tests.Infrastructure @@ -33,7 +33,7 @@ public async Task GetLanguages_ShouldReturnLanguageList_WhenResponseContainsVali mockMessageHandler .When($"https://api.calinga.io/v3/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages*") .Respond("application/json", - "[ { 'name': 'en', 'tag': '', 'isReference': true }, { 'name': 'en-GB', 'tag': '', 'isReference': false }, { 'name': 'en-GB', 'tag': 'Intranet', 'isReference': false } ]"); + @"[ { ""name"": ""en"", ""tag"": """", ""isReference"": true }, { ""name"": ""en-GB"", ""tag"": """", ""isReference"": false }, { ""name"": ""en-GB"", ""tag"": ""Intranet"", ""isReference"": false } ]"); var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); @@ -80,9 +80,10 @@ public async Task GetTranslationsAsync_WithKeyList_SendsJsonBody_WithKeyNames() if (request.Content == null) return false; if (request.Content.Headers.ContentType?.MediaType != "application/json") return false; var body = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - var parsed = JObject.Parse(body); - var keyNames = parsed["keyNames"]?.ToObject>(); - return keyNames != null && keyNames.SequenceEqual(new[] { "k1", "k2" }); + using var parsed = JsonDocument.Parse(body); + if (!parsed.RootElement.TryGetProperty("keyNames", out var keyNamesElement)) return false; + var keyNames = keyNamesElement.EnumerateArray().Select(e => e.GetString()).ToList(); + return keyNames.SequenceEqual(new[] { "k1", "k2" }); }) .Respond("application/json", "{}"); var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); From 7c9f8ab0c361d60e2f96344ec4f906d4eba857a2 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 13:22:20 +0200 Subject: [PATCH 05/23] replace throwing LanguagesNotAvailableException with InvalidOperationException --- Calinga.NET.Tests/CalingaServiceTests.cs | 12 ++++++------ Calinga.NET/Calinga.NET.csproj | 2 +- Calinga.NET/CalingaService.cs | 6 +++--- README.md | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index 02fac27..a863dad 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -613,7 +613,7 @@ public async Task GetTranslationsAsync_WithKeyList_ColdCache_ReturnsServerSubset } [TestMethod] - public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_ThrowsLanguagesNotAvailable() + public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_ThrowsInvalidOperation() { // Arrange var settings = CreateSettings(); @@ -624,7 +624,7 @@ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_Throws Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); _consumerHttpClient.Verify(x => x.GetLanguagesAsync(), Times.Never); @@ -632,7 +632,7 @@ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_Throws } [TestMethod] - public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_ThrowsLanguagesNotAvailable() + public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_ThrowsInvalidOperation() { // Arrange — warm cache must not rescue the call under UseCacheOnly; keyed calls never touch the cache. var settings = CreateSettings(); @@ -643,7 +643,7 @@ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_Throws Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 }); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); @@ -680,7 +680,7 @@ public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_ReturnsEmpty_NoHttp } [TestMethod] - public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_UseCacheOnly_ThrowsLanguagesNotAvailable() + public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_UseCacheOnly_ThrowsInvalidOperation() { // Arrange — UseCacheOnly is incompatible with the keyed overload regardless of whether the key // collection is empty. The UseCacheOnly check runs before the empty-keys short-circuit. @@ -692,7 +692,7 @@ public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_UseCacheOnly_Throws Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, Array.Empty()); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never); _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never); _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never); diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index 202e2f4..eeb1240 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -12,7 +12,7 @@ - Added `GetTranslationsAsync(string language, IEnumerable<string> keys)` to fetch a subset of translations for a given language without downloading the full dictionary. - Every keyed call issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. - Keys absent from the server response are silently omitted from the result. -- `UseCacheOnly = true` is incompatible with the keyed overload and throws `LanguagesNotAvailableException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache. +- `UseCacheOnly = true` is incompatible with the keyed overload and throws `InvalidOperationException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache. - When `UseCacheOnly` is false, passing an empty key collection returns an empty dictionary immediately (no HTTP call, no cache access). diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 0bae2cc..9d131e1 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -221,7 +221,7 @@ public async Task> GetTranslationsAsync(stri /// The translation keys to fetch. /// A dictionary containing only the requested keys that were found on the server. /// Thrown when is null. - /// + /// /// Thrown when is true. Keyed calls always /// require HTTP; they cannot be served from the cache, so this setting is incompatible with /// the keyed overload regardless of whether the key collection is empty or not. @@ -233,8 +233,8 @@ public async Task> GetTranslationsAsync(stri if (_settings.UseCacheOnly) { - throw new LanguagesNotAvailableException( - $"Keyed translations are not served from the cache; cannot be fetched while UseCacheOnly is true. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); + throw new InvalidOperationException( + $"Keyed translations cannot be fetched while {nameof(CalingaServiceSettings.UseCacheOnly)} is true; the keyed overload always requires HTTP. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); } var keySet = new HashSet(keys, StringComparer.Ordinal); diff --git a/README.md b/README.md index 382c6da..4a74ced 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ var translations = await calingaService.GetTranslationsAsync("de", keys); - Every keyed call **POSTs** to the Consumer API with a JSON body `{ "keyNames": [...] }` and returns only the translations the server responded with. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. - Keys absent from the server response are silently omitted from the result (no exception). - Passing `keys: null` throws `ArgumentNullException`. -- `UseCacheOnly = true` is incompatible with the keyed overload — passing any key collection (including an empty one) while `UseCacheOnly` is set throws `LanguagesNotAvailableException`, because keyed calls always require HTTP and cannot be served from the cache. +- `UseCacheOnly = true` is incompatible with the keyed overload — passing any key collection (including an empty one) while `UseCacheOnly` is set throws `InvalidOperationException`, because keyed calls always require HTTP and cannot be served from the cache. - When `UseCacheOnly` is false, passing an empty collection returns an empty dictionary immediately — no HTTP call, no cache access. ### Transport summary @@ -106,6 +106,6 @@ var translations = await calingaService.GetTranslationsAsync("de", keys); | `GetTranslationsAsync(language)` | `GET` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` | Yes | Full dictionary stored | | `GetTranslationsAsync(language, keys)` with non-empty keys (and `UseCacheOnly = false`) | `POST` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` with body `{ "keyNames": [...] }` | No | Not stored | | `GetTranslationsAsync(language, keys)` with empty keys (and `UseCacheOnly = false`) | none | — | No | — | -| `GetTranslationsAsync(language, keys)` with any keys while `UseCacheOnly = true` | — | — | — | Throws `LanguagesNotAvailableException` | +| `GetTranslationsAsync(language, keys)` with any keys while `UseCacheOnly = true` | — | — | — | Throws `InvalidOperationException` | Both calls share the existing `ConsumerApiBaseUrl` setting — no additional URL configuration is required. From 890e84cd91650e897e701f760c2ae0bf8c9b7046 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 13:38:44 +0200 Subject: [PATCH 06/23] Handle null in body from Consumer API and add two tests covering it --- .../Infrastructure/ConsumerHttpClientTest.cs | 39 +++++++++++++++++++ .../Infrastructure/ConsumerHttpClient.cs | 3 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs index 68a06df..b7d829f 100644 --- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs +++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs @@ -167,6 +167,45 @@ public async Task GetTranslationsAsync_WithKeyList_On500_ThrowsTranslationsNotAv await act.Should().ThrowAsync(); } + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_OnNullJsonBody_ReturnsEmptyDictionary() + { + // Arrange — the API responds 200 OK with the literal JSON value "null". + // System.Text.Json deserialises that to a CLR null; the client must surface + // an empty dictionary instead of letting null propagate to callers. + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Post, "*") + .Respond("application/json", "null"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + var result = await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [TestMethod] + public async Task GetTranslationsAsync_OnNullJsonBody_ReturnsEmptyDictionary() + { + // Arrange — same null-body scenario for the existing GET overload, since both + // paths share the CreateTranslationsDictionary helper. + var mockMessageHandler = new MockHttpMessageHandler(); + mockMessageHandler + .When(HttpMethod.Get, "*") + .Respond("application/json", "null"); + var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler)); + + // Act + var result = await sut.GetTranslationsAsync("de").ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + private static CalingaServiceSettings CreateSettings(bool isDevMode = false) { return new CalingaServiceSettings diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs index cfc9835..a23d795 100644 --- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs @@ -129,7 +129,8 @@ private async Task GetResponseBody(string url) private static Dictionary CreateTranslationsDictionary(string json) { - return JsonSerializer.Deserialize>(json)!; + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); } private static IEnumerable DeserializeLanguages(string json) From 86ee986f7426e6311035c67e8cc71bd237b986bf Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:02:22 +0200 Subject: [PATCH 07/23] improve warmup cache test --- Calinga.NET.Tests/CalingaServiceTests.cs | 9 ++++++++- Calinga.NET.Tests/TestData.cs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index a863dad..2db32f5 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -634,9 +634,16 @@ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_Throws [TestMethod] public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_ThrowsInvalidOperation() { - // Arrange — warm cache must not rescue the call under UseCacheOnly; keyed calls never touch the cache. + // Arrange — populate the cache, then call the keyed overload under UseCacheOnly. + // The UseCacheOnly check must reject the call before any cache lookup happens, + // even if the cache holds the requested key. A future change that consulted the + // cache before checking UseCacheOnly would silently make this call succeed — + // the warm cache is what makes that regression observable. var settings = CreateSettings(); settings.UseCacheOnly = true; + _cachingService + .Setup(x => x.GetTranslations(TestData.Language_DE, It.IsAny())) + .ReturnsAsync(TestData.Cache_Translations_De); var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); // Act diff --git a/Calinga.NET.Tests/TestData.cs b/Calinga.NET.Tests/TestData.cs index 9c45644..5940778 100644 --- a/Calinga.NET.Tests/TestData.cs +++ b/Calinga.NET.Tests/TestData.cs @@ -13,7 +13,7 @@ internal static class TestData internal const string Key_1 = "UnitTest_Key1"; internal const string Key_2 = "UnitTest_Key2"; internal const string Translation_Key_1 = "translation for key 1"; - internal const string Translation_Key_2 = "translation for key 1"; + internal const string Translation_Key_2 = "translation for key 2"; internal static CacheResponse Cache_Translations_De = new CacheResponse(Translations_De, true); internal static CacheResponse Cache_Translations_En = new CacheResponse(Translations_En, true); From 67c2045db63da61622f1a67f07f5db4291902a60 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:21:37 +0200 Subject: [PATCH 08/23] add KeysNotFoundException for when a list of keys is requested but the keys are not known to the server --- Calinga.NET.Tests/CalingaServiceTests.cs | 77 +++++++++++++++++++ Calinga.NET/Calinga.NET.csproj | 2 +- Calinga.NET/CalingaService.cs | 29 +++++-- .../Infrastructure/CalingaServiceSettings.cs | 9 +++ README.md | 5 +- 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index 2db32f5..52b4ee7 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -723,6 +723,83 @@ public async Task GetTranslationsAsync_WithKeyList_IsDevMode_EchoesKeys() result.Should().ContainKey(TestData.Key_1).WhoseValue.Should().Be(TestData.Key_1); } + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_IsDevMode_AllKeysPresent_EchoesAll() + { + // Arrange — every requested key is present in the server response. + // DevMode echoes each key as its own value; no exception. + var settings = CreateSettings(isDevMode: true); + var serverSubset = new Dictionary + { + { TestData.Key_1, "translation 1" }, + { TestData.Key_2, "translation 2" } + }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 }); + + // Assert + result.Should().HaveCount(2); + result.Should().ContainKey(TestData.Key_1).WhoseValue.Should().Be(TestData.Key_1); + result.Should().ContainKey(TestData.Key_2).WhoseValue.Should().Be(TestData.Key_2); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_IsDevMode_ServerOmitsKey_ThrowsKeysNotFound() + { + // Arrange — caller asks for two keys; server returns only one. + // DevMode must throw KeysNotFoundException listing the missing key(s) + // so typos and unknown keys surface at integration time rather than as + // silent omissions at runtime. + var settings = CreateSettings(isDevMode: true); + var serverSubset = new Dictionary + { + { TestData.Key_1, "some translation" } + // Key_2 intentionally omitted by the server. + }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 }); + + // Assert + var assertion = await act.Should().ThrowAsync(); + assertion.Which.MissingKeys.Should().ContainSingle().Which.Should().Be(TestData.Key_2); + assertion.Which.Message.Should().Contain(TestData.Key_2); + } + + [TestMethod] + public async Task GetTranslationsAsync_WithKeyList_NotDevMode_ServerOmitsKey_StillSilentlyOmits() + { + // Arrange — outside DevMode, the existing "silently omit" contract stays. + // The validation behaviour is DevMode-only. + var settings = CreateSettings(isDevMode: false); + var serverSubset = new Dictionary + { + { TestData.Key_1, "some translation" } + // Key_2 intentionally omitted by the server. + }; + _consumerHttpClient + .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>())) + .ReturnsAsync(serverSubset); + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + + // Act + var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 }); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(TestData.Key_1); + result.Should().NotContainKey(TestData.Key_2); + } + #endregion Keyed GetTranslationsAsync } } diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index eeb1240..db452e7 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -11,7 +11,7 @@ ## New Features - Added `GetTranslationsAsync(string language, IEnumerable<string> keys)` to fetch a subset of translations for a given language without downloading the full dictionary. - Every keyed call issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. -- Keys absent from the server response are silently omitted from the result. +- Keys absent from the server response are silently omitted from the result in normal mode. In DevMode (`IsDevMode = true`), the keyed overload validates the server response and throws `KeysNotFoundException` if any requested key is missing, with the missing keys listed in both the message and the `MissingKeys` property. - `UseCacheOnly = true` is incompatible with the keyed overload and throws `InvalidOperationException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache. - When `UseCacheOnly` is false, passing an empty key collection returns an empty dictionary immediately (no HTTP call, no cache access). diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 9d131e1..d73f5c3 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -214,8 +214,11 @@ public async Task> GetTranslationsAsync(stri /// /// The current state of never using the cache and always using server-fresh data is currently in testing and /// can be subject to change. - /// - /// Keys absent from the server response are silently omitted. + /// + /// In normal mode, keys absent from the server response are silently omitted. + /// In , the server response is validated: + /// if any requested key is missing, a is thrown so + /// developers see typos and unknown keys at integration time rather than at runtime. /// /// The language code. /// The translation keys to fetch. @@ -226,6 +229,11 @@ public async Task> GetTranslationsAsync(stri /// require HTTP; they cannot be served from the cache, so this setting is incompatible with /// the keyed overload regardless of whether the key collection is empty or not. /// + /// + /// Thrown when is true and the server response + /// does not include every requested key. The exception's + /// property exposes the missing keys for diagnostic purposes. + /// public async Task> GetTranslationsAsync(string language, IEnumerable keys) { Guard.IsNotNullOrWhiteSpace(language); @@ -246,9 +254,20 @@ public async Task> GetTranslationsAsync(stri _logger.Info($"Fetching filtered translations for language {language} ({keySet.Count} key(s)) from consumer API"); var subset = await _consumerHttpClient.GetTranslationsAsync(language, keySet).ConfigureAwait(false); - return _settings.IsDevMode - ? subset.ToDictionary(k => k.Key, k => k.Key) - : subset; + + if (_settings.IsDevMode) + { + var missingKeys = keySet.Where(k => !subset.ContainsKey(k)).ToList(); + if (missingKeys.Count > 0) + { + throw new KeysNotFoundException( + missingKeys, + $"DevMode: {missingKeys.Count} of {keySet.Count} requested key(s) not found on server. Missing: {string.Join(", ", missingKeys)}. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); + } + return subset.ToDictionary(k => k.Key, k => k.Key); + } + + return subset; } private async Task?> TryGetFromCache(string language, bool invalidateCache) diff --git a/Calinga.NET/Infrastructure/CalingaServiceSettings.cs b/Calinga.NET/Infrastructure/CalingaServiceSettings.cs index b51e3a6..1621f61 100644 --- a/Calinga.NET/Infrastructure/CalingaServiceSettings.cs +++ b/Calinga.NET/Infrastructure/CalingaServiceSettings.cs @@ -12,6 +12,15 @@ public class CalingaServiceSettings public bool IncludeDrafts { get; set; } + /// + /// When true, translation lookups still hit the server (or cache) to determine which keys exist, + /// but the returned values are the keys themselves rather than the translations. Use during UI + /// development to verify which translation key renders where without depending on translated content. + /// For the keyed GetTranslationsAsync overload, the server response is also validated: + /// if any requested key is missing on the server, a + /// is thrown listing the missing keys, so typos and unknown keys surface at integration time + /// rather than as silent omissions at runtime. + /// public bool IsDevMode { get; set; } /// diff --git a/README.md b/README.md index 4a74ced..bd250b3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Package to connect and use the calinga service in .NET applications - `Team`: The name of your team. - `Project`: The name of your project. - `ApiToken`: The API token used for authentication. -- `IsDevMode`: A boolean indicating if the service is in development mode. When `true`, it returns keys instead of actual translations. +- `IsDevMode`: When `true`, the service returns each translation key as its own value instead of the translated text. Use during UI development to verify which translation key renders where. The keyed `GetTranslationsAsync(language, keys)` overload additionally validates the server response: if any requested key is missing on the server, the call throws `KeysNotFoundException` listing the missing keys, so typos and unknown keys surface at integration time rather than as silent omissions at runtime. - `IncludeDrafts`: A boolean indicating if draft translations should be included. - `CacheDirectory`: The directory where cache files are stored. Only needed for the default caching implementation. - `MemoryCacheExpirationIntervalInSeconds`: The expiration interval for the in-memory cache in seconds. Only needed for the default caching implementation. @@ -94,7 +94,8 @@ var translations = await calingaService.GetTranslationsAsync("de", keys); ``` - Every keyed call **POSTs** to the Consumer API with a JSON body `{ "keyNames": [...] }` and returns only the translations the server responded with. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. -- Keys absent from the server response are silently omitted from the result (no exception). +- In normal mode, keys absent from the server response are silently omitted from the result (no exception). +- In DevMode (`IsDevMode = true`), the server response is validated: if any requested key is missing, the call throws `KeysNotFoundException`. The exception's `MissingKeys` property exposes the missing keys, and the message lists them too, so devs can fix typos and unknown keys immediately. - Passing `keys: null` throws `ArgumentNullException`. - `UseCacheOnly = true` is incompatible with the keyed overload — passing any key collection (including an empty one) while `UseCacheOnly` is set throws `InvalidOperationException`, because keyed calls always require HTTP and cannot be served from the cache. - When `UseCacheOnly` is false, passing an empty collection returns an empty dictionary immediately — no HTTP call, no cache access. From cc9029727ef68a8adfeccee545ccad78d648a719 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:27:00 +0200 Subject: [PATCH 09/23] add KeysNotFoundException file --- .../Exceptions/KeysNotFoundException.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs diff --git a/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs new file mode 100644 index 0000000..875e555 --- /dev/null +++ b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Calinga.NET.Infrastructure.Exceptions +{ + [Serializable] + public class KeysNotFoundException : Exception + { + public IReadOnlyCollection MissingKeys { get; } + + public KeysNotFoundException() + { + MissingKeys = Array.Empty(); + } + + public KeysNotFoundException(string message) : base(message) + { + MissingKeys = Array.Empty(); + } + + public KeysNotFoundException(string message, Exception innerException) : base(message, innerException) + { + MissingKeys = Array.Empty(); + } + + public KeysNotFoundException(IReadOnlyCollection missingKeys, string message) : base(message) + { + MissingKeys = missingKeys; + } + } +} From 7b4a71242cc8be43b1e094fb3322f5ab01fa5075 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:27:43 +0200 Subject: [PATCH 10/23] Add tests to make sure that the Newtonsoft JSON caches still work in the future. --- Calinga.NET.Tests/FileCachingServiceTests.cs | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Calinga.NET.Tests/FileCachingServiceTests.cs b/Calinga.NET.Tests/FileCachingServiceTests.cs index 5d05aaf..b692f44 100644 --- a/Calinga.NET.Tests/FileCachingServiceTests.cs +++ b/Calinga.NET.Tests/FileCachingServiceTests.cs @@ -697,5 +697,55 @@ public async Task StoreLanguagesAsync_AndStoreTranslationsAsync_ShouldNotThrow_W } #endregion + + #region Newtonsoft → System.Text.Json compatibility + + [TestMethod] + public async Task GetTranslations_ReadsNewtonsoftEraFile_Successfully() + { + // Arrange — exact byte shape Newtonsoft 13 produced for Dictionary: + // compact, double-quoted keys/values, no whitespace, no BOM. Pinning a literal here + // (instead of round-tripping through System.Text.Json) is the whole point — proves + // existing on-disk caches written by 2.1.x are still readable after the JSON-library + // swap in 2.2.0. + const string newtonsoftEraJson = "{\"key1\":\"value1\",\"key2\":\"value2\"}"; + var language = "EN"; + var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json"); + _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(newtonsoftEraJson); + + // Act + var result = await _service.GetTranslations(language, false); + + // Assert + result.FoundInCache.Should().BeTrue(); + result.Result.Should().HaveCount(2); + result.Result["key1"].Should().Be("value1"); + result.Result["key2"].Should().Be("value2"); + } + + [TestMethod] + public async Task GetLanguages_ReadsNewtonsoftEraFile_Successfully() + { + // Arrange — Newtonsoft 13 default for List: PascalCase property names, + // no whitespace, double-quoted strings, JSON booleans lowercase. Same rationale as + // the translations test — pin a literal so any future serializer-options change + // (e.g. JsonNamingPolicy.CamelCase) surfaces as a failing test, not a broken cache. + const string newtonsoftEraJson = "[{\"Name\":\"en\",\"IsReference\":true},{\"Name\":\"de\",\"IsReference\":false}]"; + var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json"); + _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true); + _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(newtonsoftEraJson); + + // Act + var result = await _service.GetLanguages(); + + // Assert + result.FoundInCache.Should().BeTrue(); + result.Result.Should().HaveCount(2); + result.Result.Should().ContainSingle(l => l.Name == "en" && l.IsReference); + result.Result.Should().ContainSingle(l => l.Name == "de" && !l.IsReference); + } + + #endregion } } From 0e8512ca93f78c6653643b97b1e81844fcf13f12 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:34:17 +0200 Subject: [PATCH 11/23] improve Nuget package page - Add README - Add Author - Add Repository --- Calinga.NET/Calinga.NET.csproj | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index db452e7..7d07f31 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -5,7 +5,13 @@ 8.0 enable Calinga.NET + conplement AG Library to integrate Calinga in .NET projects + calinga;localization;translations;i18n + README.md + https://github.com/conplementAG/Calinga.NET + https://github.com/conplementAG/Calinga.NET.git + git 2.2.0 ## New Features @@ -27,4 +33,8 @@ + + + + From 46ea80790c0b5ce95779f1373fbdaecb8ce362a6 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 14:40:39 +0200 Subject: [PATCH 12/23] add gitattributes --- .gitattributes | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..aa951a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Default: Git decides per-file based on heuristics, but normalises text on commit. +* text=auto + +# Pin LF across the board — matches the existing state of every file in the +# repo today, including the .sln. Prevents CRLF drift when contributors edit +# from Windows without an EOL-aware editor. +*.cs text eol=lf +*.csproj text eol=lf +*.sln text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.xml text eol=lf +*.props text eol=lf +*.targets text eol=lf +*.editorconfig text eol=lf +*.gitattributes text eol=lf + +# Binary — never touch. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.snk binary +*.nupkg binary +*.dll binary +*.exe binary +*.pdb binary From 27348b01580bbbe4a9c4e6db15c80032c482bc4d Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 15:21:50 +0200 Subject: [PATCH 13/23] improve Nuget package's release notes --- Calinga.NET/Calinga.NET.csproj | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index 7d07f31..5a3886e 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -16,10 +16,17 @@ ## New Features - Added `GetTranslationsAsync(string language, IEnumerable<string> keys)` to fetch a subset of translations for a given language without downloading the full dictionary. -- Every keyed call issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. +- Every call requesting a specific list of keys issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh. - Keys absent from the server response are silently omitted from the result in normal mode. In DevMode (`IsDevMode = true`), the keyed overload validates the server response and throws `KeysNotFoundException` if any requested key is missing, with the missing keys listed in both the message and the `MissingKeys` property. - `UseCacheOnly = true` is incompatible with the keyed overload and throws `InvalidOperationException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache. - When `UseCacheOnly` is false, passing an empty key collection returns an empty dictionary immediately (no HTTP call, no cache access). +- When in "DevMode" and requesting a list of keys, a KeysNotFoundException gets thrown when keys were missing from the server response + +## API contract decisions +- Filtering for a list of keys while having useCacheOnly enabled throws an InvalidOperationException + +## Documentation +- Package now ships its README on the NuGet listing and links to the GitHub source repository. From 6af38b9a912c64a59bdbcb34e49e2630aff7fe7e Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 16:30:01 +0200 Subject: [PATCH 14/23] improve exception handling in fallback case --- Calinga.NET.Tests/CalingaServiceTests.cs | 35 ++++++++++++++++-- Calinga.NET/CalingaService.cs | 37 +++++++++++++++---- Calinga.NET/ICalingaService.cs | 2 +- .../Infrastructure/ConsumerHttpClient.cs | 1 - 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index 52b4ee7..a3e84b0 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -291,7 +291,8 @@ public async Task GetReferenceLanguage_ShouldThrow_WhenUseCacheOnlyIsTrueAndNoRe Func getReferenceLanguage = async () => await service.GetReferenceLanguage(); // Assert - await getReferenceLanguage.Should().ThrowAsync(); + var assertion = await getReferenceLanguage.Should().ThrowAsync(); + assertion.WithInnerException(); _consumerHttpClient.Verify(x => x.GetLanguagesAsync(), Times.Never); } @@ -418,16 +419,20 @@ public async Task GetTranslationsAsync_ShouldNotFetchFromHttpClient_WhenUseCache [TestMethod] public async Task GetReferenceLanguage_ShouldThrow_WhenNoReferenceLanguageFound() { - // Arrange + // Arrange — non-empty language list with no reference flag. FetchLanguagesAsync succeeds, + // so there is no inner LanguagesNotAvailableException — only the outer translations failure. var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); _cachingService.Setup(x => x.GetLanguages()).ReturnsAsync(new CachedLanguageListResponse(new List(), false)); - _consumerHttpClient.Setup(x => x.GetLanguagesAsync()).ReturnsAsync(new List()); + _consumerHttpClient.Setup(x => x.GetLanguagesAsync()).ReturnsAsync(new List + { + new Language { Name = TestData.Language_DE, IsReference = false } + }); // Act Func getReferenceLanguage = async () => await service.GetReferenceLanguage(); // Assert - await getReferenceLanguage.Should().ThrowAsync(); + await getReferenceLanguage.Should().ThrowAsync(); } [TestMethod] @@ -447,6 +452,28 @@ public async Task GetTranslationsAsync_ShouldThrow_WhenTranslationsNotAvailableA await getTranslations.Should().ThrowAsync(); } + [TestMethod] + public async Task GetTranslationsAsync_ShouldThrowTranslationsNotAvailable_WhenLanguageListUnavailableDuringFallback() + { + // Arrange — UseCacheOnly with empty caches forces FetchLanguagesAsync to throw + // LanguagesNotAvailableException. Callers of GetTranslationsAsync expect a + // TranslationsNotAvailableException, with the language failure as the inner cause. + var settings = CreateSettings(); + settings.UseCacheOnly = true; + settings.FallbackToReferenceLanguage = true; + var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object); + _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, settings.IncludeDrafts)) + .ReturnsAsync(new CacheResponse(TestData.EmptyTranslations, false)); + _cachingService.Setup(x => x.GetLanguages()).ReturnsAsync(CachedLanguageListResponse.Empty); + + // Act + Func getTranslations = async () => await service.GetTranslationsAsync(TestData.Language_DE); + + // Assert + var assertion = await getTranslations.Should().ThrowAsync(); + assertion.WithInnerException(); + } + [TestMethod] public async Task GetTranslationsAsync_ShouldThrow_WhenFallbackToReferenceLanguageIsFalseOrReferenceLanguageIsSame() { diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index d73f5c3..86a5f18 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -184,15 +184,21 @@ public async Task> GetTranslationsAsync(stri translations = await TryGetFromApi(language).ConfigureAwait(false); if (translations != null) return translations; - + + if (!_settings.FallbackToReferenceLanguage) + { + throw new TranslationsNotAvailableException( + $"Translation not found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); + } + var referenceLanguage = await GetReferenceLanguage().ConfigureAwait(false); - - if (!_settings.FallbackToReferenceLanguage || referenceLanguage == language) + + if (referenceLanguage == language) { throw new TranslationsNotAvailableException( $"Translation not found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}"); } - + _logger.Warn("Translations not found, trying to fetch reference language"); language = referenceLanguage; } @@ -329,24 +335,39 @@ public async Task> GetLanguagesAsync() /// Gets the reference language for the current project. /// /// The reference language code. + /// + /// Thrown when the reference language cannot be determined — either because the language list is + /// unavailable (inner exception is ) or because the + /// list contains no language flagged as reference. Reported as a translations failure because the + /// reference language exists to drive translation fallback. + /// public async Task GetReferenceLanguage() { if (!string.IsNullOrWhiteSpace(_referenceLanguage)) return _referenceLanguage!; - var languages = (await FetchLanguagesAsync().ConfigureAwait(false)) - .ToArray(); + Language[] languages; + try + { + languages = (await FetchLanguagesAsync().ConfigureAwait(false)).ToArray(); + } + catch (LanguagesNotAvailableException ex) + { + throw new TranslationsNotAvailableException( + $"Reference language could not be determined, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}", ex); + } if (languages.All(l => !l.IsReference)) { - throw new LanguagesNotAvailableException("Reference language not found"); + throw new TranslationsNotAvailableException( + $"No reference language found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}"); } _referenceLanguage = languages.Single(l => l.IsReference).Name; return _referenceLanguage; } - + /// /// Clears the translation and language cache. /// diff --git a/Calinga.NET/ICalingaService.cs b/Calinga.NET/ICalingaService.cs index ce146b7..ad6ee75 100644 --- a/Calinga.NET/ICalingaService.cs +++ b/Calinga.NET/ICalingaService.cs @@ -3,7 +3,7 @@ namespace Calinga.NET { - public interface ICalingaService + public interface ICalingaService { Task TranslateAsync(string key, string language); diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs index a23d795..d6148c2 100644 --- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs +++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs @@ -21,7 +21,6 @@ public class ConsumerHttpClient : IConsumerHttpClient public ConsumerHttpClient(CalingaServiceSettings settings) : this(settings, new HttpClient()) { - _settings = settings; } public ConsumerHttpClient(CalingaServiceSettings settings, HttpClient httpClient) From 8dbf44808ef3e6407fde482561bd1eb182faefbc Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 16:36:04 +0200 Subject: [PATCH 15/23] make Guard actually check what it says --- Calinga.NET/Guard.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Calinga.NET/Guard.cs b/Calinga.NET/Guard.cs index 8c30af9..1e03cdc 100644 --- a/Calinga.NET/Guard.cs +++ b/Calinga.NET/Guard.cs @@ -6,7 +6,8 @@ public static class Guard { public static void IsNotNullOrWhiteSpace(string parameter) { - if (string.IsNullOrEmpty(parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty."); + parameter = parameter.Replace(" ", string.Empty); + if (string.IsNullOrEmpty(string.parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty."); } public static void IsNotNull(object parameter, string name) From 3fc401b739988672df2489d9e920104c96c2aa49 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 16:44:00 +0200 Subject: [PATCH 16/23] fix typo --- Calinga.NET/Guard.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Calinga.NET/Guard.cs b/Calinga.NET/Guard.cs index 1e03cdc..399b316 100644 --- a/Calinga.NET/Guard.cs +++ b/Calinga.NET/Guard.cs @@ -7,7 +7,7 @@ public static class Guard public static void IsNotNullOrWhiteSpace(string parameter) { parameter = parameter.Replace(" ", string.Empty); - if (string.IsNullOrEmpty(string.parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty."); + if (string.IsNullOrEmpty(parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty."); } public static void IsNotNull(object parameter, string name) From 829de79ba4dee0d3889604b172c8483d17aa85bb Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Tue, 28 Apr 2026 16:44:16 +0200 Subject: [PATCH 17/23] add more information to release notes --- Calinga.NET/Calinga.NET.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index 5a3886e..af9af33 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -26,7 +26,11 @@ - Filtering for a list of keys while having useCacheOnly enabled throws an InvalidOperationException ## Documentation -- Package now ships its README on the NuGet listing and links to the GitHub source repository. +- Package now ships its README on the NuGet listing and links to the GitHub source repository + + ## Further Changes + - The dependency on Newtonsoft was removed and replaced by System.Text.Json + - More detailed exception handling From 247ccf029c4a583b8abce688c9ffc597cf4ddf2f Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Wed, 29 Apr 2026 09:29:04 +0200 Subject: [PATCH 18/23] Update docs of 2.2.0 --- Calinga.NET/Calinga.NET.csproj | 2 ++ Calinga.NET/CalingaService.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index af9af33..cfd539a 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -31,6 +31,8 @@ ## Further Changes - The dependency on Newtonsoft was removed and replaced by System.Text.Json - More detailed exception handling + - `GetReferenceLanguage()` now throws `TranslationsNotAvailableException` (previously `LanguagesNotAvailableException`). The reference language exists to drive translation fallback, so a failure to determine it is reported as a translations failure. The original `LanguagesNotAvailableException` is preserved as the inner exception when the language list itself was unavailable. + - `GetTranslationsAsync(string language)` no longer leaks `LanguagesNotAvailableException` out of the reference-language fallback path — callers consistently see `TranslationsNotAvailableException`. diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 86a5f18..759aa28 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -166,6 +166,17 @@ public async Task TranslateAsync(string key, string language) /// The language code. /// If true, bypasses the cache and fetches from the API. Do not use in combination with "UseCacheOnly" /// A dictionary of translation keys and values. + /// + /// Thrown when is true while + /// is true. + /// + /// + /// Thrown when translations cannot be retrieved from cache or API and either + /// is false or the reference-language + /// fallback could not produce translations either. When the failure originates from the language list + /// being unavailable during fallback, the underlying is + /// preserved as the inner exception. + /// public async Task> GetTranslationsAsync(string language, bool invalidateCache) { Guard.IsNotNullOrWhiteSpace(language); @@ -209,6 +220,13 @@ public async Task> GetTranslationsAsync(stri /// /// The language code. /// A dictionary of translation keys and values. + /// + /// Thrown when translations cannot be retrieved from cache or API and either + /// is false or the reference-language + /// fallback could not produce translations either. When the failure originates from the language list + /// being unavailable during fallback, the underlying is + /// preserved as the inner exception. + /// public async Task> GetTranslationsAsync(string language) { return await GetTranslationsAsync(language, false); From dda920aeb5addef58ee49d36d7aa83001911c759 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Wed, 29 Apr 2026 10:19:15 +0200 Subject: [PATCH 19/23] add more release notes and fix guard --- Calinga.NET/Calinga.NET.csproj | 3 ++- Calinga.NET/Guard.cs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj index cfd539a..a149aaf 100644 --- a/Calinga.NET/Calinga.NET.csproj +++ b/Calinga.NET/Calinga.NET.csproj @@ -30,9 +30,10 @@ ## Further Changes - The dependency on Newtonsoft was removed and replaced by System.Text.Json - - More detailed exception handling - `GetReferenceLanguage()` now throws `TranslationsNotAvailableException` (previously `LanguagesNotAvailableException`). The reference language exists to drive translation fallback, so a failure to determine it is reported as a translations failure. The original `LanguagesNotAvailableException` is preserved as the inner exception when the language list itself was unavailable. - `GetTranslationsAsync(string language)` no longer leaks `LanguagesNotAvailableException` out of the reference-language fallback path — callers consistently see `TranslationsNotAvailableException`. + - Translations responses with a `null` JSON body no longer crash with `NullReferenceException`; the call now returns an empty dictionary. + - `Guard.IsNotNullOrWhiteSpace` (used to validate `language`/`key` arguments on every public API entry point) now correctly rejects whitespace-only strings — including tabs, newlines, and other Unicode whitespace — and is null-safe. Callers passing such values previously slipped past validation or hit a `NullReferenceException`; they now see `ArgumentNullException` immediately. diff --git a/Calinga.NET/Guard.cs b/Calinga.NET/Guard.cs index 399b316..5948cc6 100644 --- a/Calinga.NET/Guard.cs +++ b/Calinga.NET/Guard.cs @@ -6,8 +6,7 @@ public static class Guard { public static void IsNotNullOrWhiteSpace(string parameter) { - parameter = parameter.Replace(" ", string.Empty); - if (string.IsNullOrEmpty(parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty."); + if (string.IsNullOrWhiteSpace(parameter)) throw new ArgumentNullException($"Parameter cannot be null, empty, or whitespace."); } public static void IsNotNull(object parameter, string name) From 16eedaadffba63e77ff2efbe2792c503bf05ced9 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Wed, 13 May 2026 10:15:20 +0200 Subject: [PATCH 20/23] resolve PR comments --- Calinga.NET/CalingaService.cs | 22 +++++++++---------- Calinga.NET/Guard.cs | 8 +++---- .../Exceptions/KeysNotFoundException.cs | 16 -------------- .../LanguagesNotAvailableException.cs | 1 - .../TranslationsNotAvailableException.cs | 1 - Calinga.NET/LanguageContext.cs | 4 ++-- 6 files changed, 17 insertions(+), 35 deletions(-) diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs index 759aa28..5e128d6 100644 --- a/Calinga.NET/CalingaService.cs +++ b/Calinga.NET/CalingaService.cs @@ -124,7 +124,7 @@ public CalingaService(ICachingService cachingService, CalingaServiceSettings set /// A language context for translation operations. public ILanguageContext CreateContext(string language) { - Guard.IsNotNullOrWhiteSpace(language); + Guard.IsNotNullOrWhiteSpace(language, nameof(language)); return new LanguageContext(language, this); } @@ -137,8 +137,8 @@ public ILanguageContext CreateContext(string language) /// The translated string or the key if not found. public async Task TranslateAsync(string key, string language) { - Guard.IsNotNullOrWhiteSpace(language); - Guard.IsNotNullOrWhiteSpace(key); + Guard.IsNotNullOrWhiteSpace(language, nameof(language)); + Guard.IsNotNullOrWhiteSpace(key, nameof(key)); if (_settings.IsDevMode) return key; @@ -179,7 +179,7 @@ public async Task TranslateAsync(string key, string language) /// public async Task> GetTranslationsAsync(string language, bool invalidateCache) { - Guard.IsNotNullOrWhiteSpace(language); + Guard.IsNotNullOrWhiteSpace(language, nameof(language)); if (invalidateCache && _settings.UseCacheOnly) { @@ -227,9 +227,9 @@ public async Task> GetTranslationsAsync(stri /// being unavailable during fallback, the underlying is /// preserved as the inner exception. /// - public async Task> GetTranslationsAsync(string language) + public Task> GetTranslationsAsync(string language) { - return await GetTranslationsAsync(language, false); + return GetTranslationsAsync(language, false); } /// @@ -260,7 +260,7 @@ public async Task> GetTranslationsAsync(stri /// public async Task> GetTranslationsAsync(string language, IEnumerable keys) { - Guard.IsNotNullOrWhiteSpace(language); + Guard.IsNotNullOrWhiteSpace(language, nameof(language)); if (keys == null) throw new ArgumentNullException(nameof(keys)); if (_settings.UseCacheOnly) @@ -412,7 +412,7 @@ private async Task> FetchLanguagesAsync() if (foundList != null && foundList.Any()) { - await _cachingService.StoreLanguagesAsync(foundList); + await _cachingService.StoreLanguagesAsync(foundList).ConfigureAwait(false); } } } @@ -429,9 +429,9 @@ private async Task> FetchLanguagesAsync() private static void ValidateSettings(CalingaServiceSettings setting) { Guard.IsNotNull(setting, nameof(setting)); - Guard.IsNotNullOrWhiteSpace(setting.Project); - Guard.IsNotNullOrWhiteSpace(setting.Organization); - Guard.IsNotNullOrWhiteSpace(setting.CacheDirectory); + Guard.IsNotNullOrWhiteSpace(setting.Project, nameof(setting.Project)); + Guard.IsNotNullOrWhiteSpace(setting.Organization, nameof(setting.Organization)); + Guard.IsNotNullOrWhiteSpace(setting.CacheDirectory, nameof(setting.CacheDirectory)); } } } diff --git a/Calinga.NET/Guard.cs b/Calinga.NET/Guard.cs index 5948cc6..cd31544 100644 --- a/Calinga.NET/Guard.cs +++ b/Calinga.NET/Guard.cs @@ -1,17 +1,17 @@ -using System; +using System; namespace Calinga.NET { public static class Guard { - public static void IsNotNullOrWhiteSpace(string parameter) + public static void IsNotNullOrWhiteSpace(string parameter, string name) { - if (string.IsNullOrWhiteSpace(parameter)) throw new ArgumentNullException($"Parameter cannot be null, empty, or whitespace."); + if (string.IsNullOrWhiteSpace(parameter)) throw new ArgumentNullException(name, "Parameter cannot be null, empty, or whitespace."); } public static void IsNotNull(object parameter, string name) { - if (parameter == null) throw new ArgumentNullException($"Parameter {name} cannot be null or empty."); + if (parameter == null) throw new ArgumentNullException(name, "Parameter cannot be null or empty."); } } } diff --git a/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs index 875e555..124d60c 100644 --- a/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs +++ b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs @@ -3,26 +3,10 @@ namespace Calinga.NET.Infrastructure.Exceptions { - [Serializable] public class KeysNotFoundException : Exception { public IReadOnlyCollection MissingKeys { get; } - public KeysNotFoundException() - { - MissingKeys = Array.Empty(); - } - - public KeysNotFoundException(string message) : base(message) - { - MissingKeys = Array.Empty(); - } - - public KeysNotFoundException(string message, Exception innerException) : base(message, innerException) - { - MissingKeys = Array.Empty(); - } - public KeysNotFoundException(IReadOnlyCollection missingKeys, string message) : base(message) { MissingKeys = missingKeys; diff --git a/Calinga.NET/Infrastructure/Exceptions/LanguagesNotAvailableException.cs b/Calinga.NET/Infrastructure/Exceptions/LanguagesNotAvailableException.cs index a379561..0932dc4 100644 --- a/Calinga.NET/Infrastructure/Exceptions/LanguagesNotAvailableException.cs +++ b/Calinga.NET/Infrastructure/Exceptions/LanguagesNotAvailableException.cs @@ -2,7 +2,6 @@ namespace Calinga.NET.Infrastructure.Exceptions { - [Serializable] public class LanguagesNotAvailableException : Exception { public LanguagesNotAvailableException() diff --git a/Calinga.NET/Infrastructure/Exceptions/TranslationsNotAvailableException.cs b/Calinga.NET/Infrastructure/Exceptions/TranslationsNotAvailableException.cs index 253b509..e335f97 100644 --- a/Calinga.NET/Infrastructure/Exceptions/TranslationsNotAvailableException.cs +++ b/Calinga.NET/Infrastructure/Exceptions/TranslationsNotAvailableException.cs @@ -2,7 +2,6 @@ namespace Calinga.NET.Infrastructure.Exceptions { - [Serializable] public class TranslationsNotAvailableException : Exception { public TranslationsNotAvailableException() diff --git a/Calinga.NET/LanguageContext.cs b/Calinga.NET/LanguageContext.cs index 50e0359..d94eae5 100644 --- a/Calinga.NET/LanguageContext.cs +++ b/Calinga.NET/LanguageContext.cs @@ -9,7 +9,7 @@ public class LanguageContext : ILanguageContext public LanguageContext(string language, ICalingaService service) { - Guard.IsNotNullOrWhiteSpace(language); + Guard.IsNotNullOrWhiteSpace(language, nameof(language)); _language = language; _service = service; @@ -17,7 +17,7 @@ public LanguageContext(string language, ICalingaService service) public Task TranslateAsync(string key) { - Guard.IsNotNullOrWhiteSpace(key); + Guard.IsNotNullOrWhiteSpace(key, nameof(key)); return _service.TranslateAsync(key, _language); } } From e0b5933818b0933acb277d998e19bae522aa5c4c Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Fri, 15 May 2026 12:47:02 +0200 Subject: [PATCH 21/23] add Etag caching to Calinga.NET --- Calinga.NET.Tests/CalingaServiceTests.cs | 175 ++++++++++- Calinga.NET.Tests/Context/TestContext.cs | 284 +++++++++--------- Calinga.NET.Tests/FileCachingServiceTests.cs | 135 ++++++++- .../InMemoryCachingServiceTests.cs | 88 +++++- .../Infrastructure/ConsumerHttpClientTest.cs | 100 +++++- Calinga.NET.Tests/TestData.cs | 3 + Calinga.NET/Caching/CacheResponse.cs | 16 +- Calinga.NET/Caching/CascadedCachingService.cs | 11 +- Calinga.NET/Caching/FileCachingService.cs | 65 +++- Calinga.NET/Caching/ICachingService.cs | 2 + Calinga.NET/Caching/InMemoryCachingService.cs | 38 ++- Calinga.NET/CalingaService.cs | 67 +++-- Calinga.NET/DateTimeService.cs | 2 +- .../Infrastructure/ConsumerHttpClient.cs | 29 +- .../Infrastructure/IConsumerHttpClient.cs | 4 +- .../TranslationsHttpResponse.cs | 27 ++ 16 files changed, 825 insertions(+), 221 deletions(-) create mode 100644 Calinga.NET/Infrastructure/TranslationsHttpResponse.cs 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()); + } +} From 475156cc35fe2f4c8a008f65d37e92e3c50dfb0c Mon Sep 17 00:00:00 2001 From: Bastian Wunderlich Date: Mon, 18 May 2026 13:54:07 +0200 Subject: [PATCH 22/23] fix pr not building --- Calinga.NET.Tests/CalingaServiceTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs index 5931fc0..2cce94b 100644 --- a/Calinga.NET.Tests/CalingaServiceTests.cs +++ b/Calinga.NET.Tests/CalingaServiceTests.cs @@ -843,7 +843,7 @@ public async Task GetTranslationsAsync_StaleCache_ServerReturns304_ReturnsCached // 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); + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundTranslationsInCache: 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)) @@ -867,7 +867,7 @@ public async Task GetTranslationsAsync_StaleCache_ServerReturns200_StoresNewTran // 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); + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundTranslationsInCache: 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)) @@ -907,7 +907,7 @@ 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)); + .ReturnsAsync(new CacheResponse(TestData.Translations_De, foundTranslationsInCache: true, etag: "\"abc\"", isStale: false)); var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings); // Act @@ -927,7 +927,7 @@ public async Task GetTranslationsAsync_InvalidateCache_StillSendsIfNoneMatch_Whe // 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)); + .ReturnsAsync(new CacheResponse(TestData.Translations_De, foundTranslationsInCache: 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); @@ -948,7 +948,7 @@ public async Task GetTranslationsAsync_UseCacheOnly_StaleData_ReturnsStaleWithou // 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); + var staleCacheResponse = new CacheResponse(TestData.Translations_De, foundTranslationsInCache: 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); From f598f809e23f4fc1fdcb37d0e60547edcadb451b Mon Sep 17 00:00:00 2001 From: Bastian Wunderlich Date: Mon, 18 May 2026 14:55:49 +0200 Subject: [PATCH 23/23] fix failing tests --- Calinga.NET.Tests/CascadedCachingServiceTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Calinga.NET.Tests/CascadedCachingServiceTests.cs b/Calinga.NET.Tests/CascadedCachingServiceTests.cs index a6be37b..39af0db 100644 --- a/Calinga.NET.Tests/CascadedCachingServiceTests.cs +++ b/Calinga.NET.Tests/CascadedCachingServiceTests.cs @@ -76,15 +76,15 @@ public async Task GetTranslations_ShouldNotFail_WhenNoCacheHitInAnyLevel() public async Task StoreTranslation_ShouldAddTranslationToAllLevels() { // Arrange - _firstLevelCachingService.Setup(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>())); - _secondLevelCachingService.Setup(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>())); + _firstLevelCachingService.Setup(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>(), It.IsAny())); + _secondLevelCachingService.Setup(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>(), It.IsAny())); // Act await _sut.StoreTranslationsAsync(TestData.Language_DE, TestData.Translations_De); // Assert - _firstLevelCachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Once); - _secondLevelCachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Once); + _firstLevelCachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); + _secondLevelCachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); } [TestMethod] @@ -110,7 +110,7 @@ public async Task GetTranslations_ShouldBackfillFirstLevel_WhenSecondLevelHasDat .ReturnsAsync(CacheResponse.Empty); _secondLevelCachingService.Setup(x => x.GetTranslations(TestData.Language_DE, false)) .ReturnsAsync(TestData.Cache_Translations_De); - _firstLevelCachingService.Setup(x => x.StoreTranslationsAsync(TestData.Language_DE, It.IsAny>())) + _firstLevelCachingService.Setup(x => x.StoreTranslationsAsync(TestData.Language_DE, It.IsAny>(), It.IsAny())) .Returns(Task.CompletedTask); // Act @@ -120,7 +120,7 @@ public async Task GetTranslations_ShouldBackfillFirstLevel_WhenSecondLevelHasDat actual.Result.Should().BeEquivalentTo(TestData.Translations_De); _firstLevelCachingService.Verify( x => x.StoreTranslationsAsync(TestData.Language_DE, It.Is>( - dict => dict.Count == TestData.Translations_De.Count)), + dict => dict.Count == TestData.Translations_De.Count), It.IsAny()), Times.Once, "First level cache should be backfilled when second level has data"); } @@ -138,7 +138,7 @@ public async Task GetTranslations_ShouldNotBackfill_WhenFirstLevelHasData() // Assert actual.Result.Should().BeEquivalentTo(TestData.Translations_De); _firstLevelCachingService.Verify( - x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), + x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never, "No backfill should occur when first level already has data"); _secondLevelCachingService.Verify(