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
diff --git a/Calinga.NET.Tests/Calinga.NET.Tests.csproj b/Calinga.NET.Tests/Calinga.NET.Tests.csproj
index b32ace7..c857592 100644
--- a/Calinga.NET.Tests/Calinga.NET.Tests.csproj
+++ b/Calinga.NET.Tests/Calinga.NET.Tests.csproj
@@ -18,9 +18,9 @@
-
-
-
+
+
+
@@ -29,7 +29,7 @@
-
+
diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs
index 0b1a49d..2cce94b 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);
}
@@ -346,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
@@ -385,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));
@@ -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()
{
@@ -480,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);
}
@@ -503,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);
}
@@ -523,5 +554,438 @@ 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_ThrowsInvalidOperation()
+ {
+ // 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_ThrowsInvalidOperation()
+ {
+ // 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
+ 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_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.
+ 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);
+ }
+
+ [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
+
+ #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, 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))
+ .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, 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))
+ .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, foundTranslationsInCache: 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, 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);
+
+ // 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, 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);
+
+ // 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/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(
diff --git a/Calinga.NET.Tests/Context/TestContext.cs b/Calinga.NET.Tests/Context/TestContext.cs
index 7e86532..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 Newtonsoft.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(JsonConvert.SerializeObject(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 3acfc23..525c5cd 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));
@@ -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);
}
@@ -165,13 +165,13 @@ 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);
// Assert
- Assert.IsTrue(result.FoundInCache);
+ Assert.IsTrue(result.FoundTranslationsInCache);
CollectionAssert.AreEquivalent(translations.ToList(), result.Result.ToList());
}
@@ -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));
@@ -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);
}
@@ -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);
@@ -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
@@ -697,5 +697,180 @@ 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.FoundTranslationsInCache.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
+
+ #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 e807b3b..9ca14e2 100644
--- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs
+++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs
@@ -1,11 +1,17 @@
using System;
using System.Collections.Generic;
+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;
+using Calinga.NET.Infrastructure.Exceptions;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Text.Json;
using RichardSzalay.MockHttp;
namespace Calinga.NET.Tests.Infrastructure
@@ -29,7 +35,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));
@@ -45,6 +51,259 @@ 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();
+ 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));
+
+ // 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();
+ }
+
+ [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.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)
{
return new CalingaServiceSettings
diff --git a/Calinga.NET.Tests/TestData.cs b/Calinga.NET.Tests/TestData.cs
index 9c45644..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
@@ -13,7 +14,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);
@@ -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 f8adedd..8a34b49 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,9 +41,10 @@ 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();
-
- return new CacheResponse(dict, true);
+ : JsonSerializer.Deserialize>(fileContent) ?? new Dictionary();
+
+ var etag = await TryReadETagAsync(languageName).ConfigureAwait(false);
+ return new CacheResponse(dict, true, etag);
}
catch (IOException ex)
{
@@ -66,7 +67,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);
}
@@ -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;
@@ -123,9 +127,9 @@ public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary>(tempFileContent);
+ JsonSerializer.Deserialize>(tempFileContent);
if (_fileSystem.FileExists(path))
{
@@ -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)
@@ -172,9 +213,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))
{
@@ -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/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj
index a3e238c..a149aaf 100644
--- a/Calinga.NET/Calinga.NET.csproj
+++ b/Calinga.NET/Calinga.NET.csproj
@@ -5,30 +5,50 @@
8.0
enable
Calinga.NET
+ conplement AG
Library to integrate Calinga in .NET projects
- 2.1.4
+ calinga;localization;translations;i18n
+ README.md
+ https://github.com/conplementAG/Calinga.NET
+ https://github.com/conplementAG/Calinga.NET.git
+ git
+ 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 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
+
+ ## Further Changes
+ - The dependency on Newtonsoft was removed and replaced by System.Text.Json
+ - `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.