From 617cb891c02e0df779f0a49bef2f434e7cd26d85 Mon Sep 17 00:00:00 2001 From: baswunderlich Date: Fri, 24 Apr 2026 12:36:24 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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)