using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; using BTCPayServer.Data; using BTCPayServer.Abstractions.Models; using BTCPayServer.Plugins.Translations; using BTCPayServer.Services; using Dapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Playwright; using Newtonsoft.Json.Linq; using static Microsoft.Playwright.Assertions; using Xunit; namespace BTCPayServer.Tests { [Collection(nameof(NonParallelizableCollectionDefinition))] public class LanguageServiceTests : UnitTestBase { private const string ManifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json"; private class RealHttpClientFactory : IHttpClientFactory { public HttpClient CreateClient(string name) => new(); } public const int TestTimeout = TestUtils.TestTimeout; private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, false); } public LanguageServiceTests(ITestOutputHelper helper) : base(helper) { } private static LanguagePackUpdateService CreateLanguageService(HttpMessageHandler handler) => new(new TestHttpClientFactory(handler), new MemoryCache(new MemoryCacheOptions())); [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_ParsesManifestEntries() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse( """ { "Languages": [ { "Name": "French", "Native": "Francais", "File": "translations/french.json", "Sha": "sha-fr", "Maintainer": "alice|https://github.com/alice", "Updated": "2026-05-01T10:00:00Z" }, { "Name": "German", "Native": "Deutsch", "File": "translations/german.json", "Sha": "sha-de", "Maintainer": null, "Updated": "invalid" } ] } """)); var service = CreateLanguageService(handler); var languages = await service.GetManifestLanguages(); Assert.Equal(2, languages.Length); var french = languages.Single(l => l.Name == "French"); Assert.Equal("Francais", french.Native); Assert.Equal("alice", french.MaintainerHandle); Assert.Equal("https://github.com/alice", french.MaintainerUrl); Assert.NotNull(french.Updated); Assert.Equal("translations/french.json", french.File); Assert.Equal("sha-fr", french.Sha); var german = languages.Single(l => l.Name == "German"); Assert.Equal("Deutsch", german.Native); Assert.Null(german.MaintainerHandle); Assert.Null(german.MaintainerUrl); Assert.Null(german.Updated); } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task LanguagePackUpdateService_ParsesRtlFlag() { // Hits the real translator manifest var service = new LanguagePackUpdateService(new RealHttpClientFactory(), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())); var languages = await service.GetManifestLanguages(); Assert.True(languages.Single(l => l.Name == "Arabic").Rtl); Assert.False(languages.Single(l => l.Name == "French").Rtl); } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task LanguagePackUpdateService_FetchReturnsRtlFromManifest() { var service = new LanguagePackUpdateService(new RealHttpClientFactory(), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())); var (translationsJson, version, rtl) = await service.FetchLanguagePackFromRepository("Arabic"); Assert.True(rtl); Assert.False(string.IsNullOrEmpty(version)); Assert.NotNull(JObject.Parse(translationsJson)); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_PropagatesExceptionOnMalformedManifest() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse("{ this is not: valid json")); var service = CreateLanguageService(handler); await Assert.ThrowsAnyAsync(() => service.GetManifestLanguages()); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_ThrowsOnMissingLanguagesKey() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse("{ \"OtherField\": [] }")); var service = CreateLanguageService(handler); await Assert.ThrowsAsync(() => service.GetManifestLanguages()); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_ThrowsArgumentExceptionForUnknownLanguage() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse( """{ "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "deadbeef" } ] }""")); var service = CreateLanguageService(handler); await Assert.ThrowsAsync( () => service.FetchLanguagePackFromRepository("Klingon")); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_ThrowsWhenManifestFails() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("error", Encoding.UTF8, "text/plain") }); var service = CreateLanguageService(handler); await Assert.ThrowsAnyAsync(() => service.GetManifestLanguages()); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_FetchesLanguagePackFromManifest() { const string body = "{\"Hello\":\"Bonjour\"}"; var expectedSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(body))); var handler = new TestHttpMessageHandler(); var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json"; handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse( $$""" { "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "{{expectedSha}}" } ] } """)); handler.Register(translationUrl, () => TestHttpMessageHandler.JsonResponse(body)); var service = CreateLanguageService(handler); var (translationsJson, version, rtl) = await service.FetchLanguagePackFromRepository("French"); Assert.Equal(expectedSha, version, ignoreCase: true); Assert.False(rtl); Assert.Equal("Bonjour", JObject.Parse(translationsJson)["Hello"]?.ToString()); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_RejectsLanguagePackOnShaMismatch() { const string body = "{\"Hello\":\"Bonjour\"}"; var handler = new TestHttpMessageHandler(); var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json"; handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse( """ { "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } ] } """)); handler.Register(translationUrl, () => TestHttpMessageHandler.JsonResponse(body)); var service = CreateLanguageService(handler); var ex = await Assert.ThrowsAsync( () => service.FetchLanguagePackFromRepository("French")); Assert.Contains("SHA-256 mismatch", ex.Message); } [Fact(Timeout = TestTimeout)] [Trait("Unit", "Unit")] public async Task LanguagePackUpdateService_UsesUpdateCacheUntilInvalidated() { var handler = new TestHttpMessageHandler(); handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse( """ { "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "sha-1" } ] } """)); var service = CreateLanguageService(handler); var outdatedMetadata = JObject.Parse("{ \"version\": \"sha-0\" }"); var upToDateMetadata = JObject.Parse("{ \"version\": \"sha-1\" }"); var first = await service.CheckForLanguagePackUpdateCached("French", outdatedMetadata); Assert.True(first); var cached = await service.CheckForLanguagePackUpdateCached("French", upToDateMetadata); Assert.True(cached); service.InvalidateCache("French"); var refreshed = await service.CheckForLanguagePackUpdateCached("French", upToDateMetadata); Assert.False(refreshed); } void ActivateLangs(ServerTester s) { TestLogs.LogInformation("Activating Langs..."); var dir = TestUtils.GetTestDataFullPath("Langs"); var langdir = Path.Combine(s.PayTester._Directory, "Langs"); Directory.CreateDirectory(langdir); foreach (var file in Directory.GetFiles(dir)) File.Copy(file, Path.Combine(langdir, Path.GetFileName(file))); } [Fact(Timeout = TestTimeout)] [Trait("Playwright", "Playwright")] public async Task CanTranslateLoginPage() { await using var tester = CreatePlaywrightTester(newDb: true); ActivateLangs(tester.Server); await tester.StartAsync(); await tester.Server.PayTester.RestartStartupTask(); // Check if the Cypherpunk translation has been loaded from the file await tester.RegisterNewUser(true); await tester.CreateNewStore(); await tester.GoToServer(Views.Server.ServerNavPages.Translations); await tester.Page.Locator("#Select-Cypherpunk").ClickAsync(); await tester.Logout(); await Expect(tester.Page.Locator("label[for=\"Password\"]")).ToContainTextAsync("Cyphercode"); await Expect(tester.Page.GetByTestId("header")).ToContainTextAsync("Yo at BTCPay Server"); // Create English (Custom) await tester.LogIn(tester.CreatedUser); await tester.GoToServer(Views.Server.ServerNavPages.Translations); await tester.ClickPagePrimary(); await tester.Page.Locator("[name='Name']").FillAsync("English (Custom)"); await tester.ClickPagePrimary(); var translations = tester.Page.Locator("[name='Translations']"); await translations.ClearAsync(); await translations.FillAsync("{ \"Password\": \"Mot de passe\" }"); await tester.ClickPagePrimary(); // Check English (Custom) can be selected await tester.Page.Locator("#Select-English\\ \\(Custom\\)").ClickAsync(); await tester.Logout(); await Expect(tester.Page.Locator("label[for=\"Password\"]")).ToContainTextAsync("Mot de passe"); // Check if we can remove English (Custom) await tester.LogIn(tester.CreatedUser); await tester.GoToServer(Views.Server.ServerNavPages.Translations); await tester.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); var text = await tester.Page.ContentAsync(); Assert.Contains("Select-Cypherpunk", text); Assert.DoesNotContain("Select-English (Custom)", text); // Cypherpunk is loaded from file, can't edit Assert.DoesNotContain("Delete-Cypherpunk", text); // English (Custom) is selected, can't edit Assert.DoesNotContain("Delete-English (Custom)", text); await tester.Page.Locator("#Select-Cypherpunk").ClickAsync(); await tester.Page.Locator("#Delete-English\\ \\(Custom\\)").ClickAsync(); await tester.Page.Locator("#ConfirmInput").FillAsync("Delete"); await tester.Page.Locator("#ConfirmContinue").ClickAsync(); var alertMessage = await tester.FindAlertMessage(); Assert.Contains("Translation English (Custom) deleted", await alertMessage.TextContentAsync()); var pageContent = await tester.Page.ContentAsync(); Assert.DoesNotContain("Select-English (Custom)", pageContent); } [Fact(Timeout = TestTimeout)] [Trait("Playwright", "Playwright")] public async Task LanguagePack_IsNotEditable_AndCanBeUninstalled_WithFallbackProtection() { await using var tester = CreatePlaywrightTester(newDb: true); await tester.StartAsync(); await tester.RegisterNewUser(true); await tester.CreateNewStore(); var factory = tester.Server.PayTester.GetService(); var db = factory.CreateContext().Database.GetDbConnection(); await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('French', 'English', 'LanguagePack')"); await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('FrenchCustom', 'French', 'Custom')"); await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('German', 'English', 'LanguagePack')"); await tester.GoToServer(Views.Server.ServerNavPages.Translations); await tester.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); await Expect(tester.Page.Locator("#Delete-French")).ToBeVisibleAsync(); await Expect(tester.Page.Locator("#Delete-German")).ToBeVisibleAsync(); await Expect(tester.Page.Locator("a[href='/server/translations/French']")).ToHaveCountAsync(0); await tester.Page.Locator("#Delete-French").ClickAsync(); await tester.Page.Locator("#ConfirmInput").FillAsync("Delete"); await tester.Page.Locator("#ConfirmContinue").ClickAsync(); var fallbackAlert = await tester.FindAlertMessage(StatusMessageModel.StatusSeverity.Error); Assert.Contains("Translation French cannot be uninstalled because it is used as fallback by: FrenchCustom", await fallbackAlert.TextContentAsync()); await tester.Page.Locator("#Delete-German").ClickAsync(); await tester.Page.Locator("#ConfirmInput").FillAsync("Delete"); await tester.Page.Locator("#ConfirmContinue").ClickAsync(); var successAlert = await tester.FindAlertMessage(); Assert.Contains("Translation German deleted", await successAlert.TextContentAsync()); var german = await db.QueryFirstOrDefaultAsync("SELECT 1 FROM lang_dictionaries WHERE dict_id='German'"); Assert.Null(german); } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanUpdateTranslationsInDatabase() { using var tester = CreateServerTester(newDb: true); await tester.StartAsync(); var localizer = tester.PayTester.GetService(); var factory = tester.PayTester.GetService(); var db = factory.CreateContext().Database.GetDbConnection(); TestLogs.LogInformation("French fallback to english"); await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('French', 'English', NULL)"); async Task SetTranslation(string translationId, (string Sentence, string Translation)[] translations) { var translation = await localizer.GetTranslation(translationId); Assert.NotNull(translation); var t = new Translations(translations.Select(t => KeyValuePair.Create(t.Sentence, t.Translation))); await localizer.Save(translation, t); } async Task AssertTranslations(string translationId, (string Sentence, string Expected)[] expectations) { var all = await db.QueryAsync<(string sentence, string translation)>("SELECT sentence, translation from translations WHERE dict_id=@dictId", new { dictId = translationId }); foreach (var expectation in expectations) { if (expectation.Expected is not null) Assert.Equal(expectation.Expected, all.Single(a => a.sentence == expectation.Sentence).translation); else Assert.DoesNotContain(all, a => a.sentence == expectation.Sentence); } } await SetTranslation("English", [ ("Hello", "Hello"), ("Goodbye", "Goodbye"), ("Good afternoon", "Good afternoon") ]); await SetTranslation("French", [ ("Hello", "Salut"), ("Good afternoon", "Bonne aprem") ]); TestLogs.LogInformation("French should override Hello and Good afternoon, but not Goodbye"); await AssertTranslations("French", [("Hello", "Salut"), ("Good afternoon", "Bonne aprem"), ("Goodbye", "Goodbye"), ("lol", null)]); await AssertTranslations("English", [("Hello", "Hello"), ("Good afternoon", "Good afternoon"), ("Goodbye", "Goodbye"), ("lol", null)]); TestLogs.LogInformation("Can use fallback by setting null to a sentence"); await SetTranslation("French", [ ("Good afternoon", "Bonne aprem"), ("Goodbye", "Goodbye"), ("Hello", null) ]); await AssertTranslations("French", [("Hello", "Hello"), ("Good afternoon", "Bonne aprem"), ("Goodbye", "Goodbye"), ("lol", null)]); TestLogs.LogInformation("Can use fallback by setting same as fallback to a sentence"); await SetTranslation("French", [ ("Good afternoon", "Good afternoon") ]); await AssertTranslations("French", [("Hello", "Hello"), ("Good afternoon", "Good afternoon"), ("Goodbye", "Goodbye"), ("lol", null)]); await SetTranslation("English", [ ("Hello", null as string), ("Good afternoon", "Good afternoon"), ("Goodbye", "Goodbye") ]); await AssertTranslations("French", [("Hello", null), ("Good afternoon", "Good afternoon"), ("Goodbye", "Goodbye"), ("lol", null)]); await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id='French'"); await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id='English'"); } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanStoreAndReadRtlMetadata() { using var tester = CreateServerTester(newDb: true); await tester.StartAsync(); var localizer = tester.PayTester.GetService(); var factory = tester.PayTester.GetService(); var db = factory.CreateContext().Database.GetDbConnection(); await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('Arabic', 'English', 'LanguagePack')"); await localizer.UpdateMetadata("Arabic", "sha-ar", true); var loaded = await localizer.GetTranslations("Arabic"); Assert.True(loaded.Rtl); var translation = await localizer.GetTranslation("Arabic"); Assert.NotNull(translation); Assert.Equal("sha-ar", translation.Metadata["version"]?.ToString()); Assert.True(translation.Metadata["rtl"]?.Value()); await localizer.UpdateMetadata("Arabic", "sha-ar2", false); Assert.False((await localizer.GetTranslations("Arabic")).Rtl); Assert.Equal("sha-ar2", (await localizer.GetTranslation("Arabic"))!.Metadata["version"]?.ToString()); Assert.False((await localizer.GetTranslations("English")).Rtl); await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id='Arabic'"); } [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanAutoDetectLanguage() { using var tester = CreateServerTester(); await tester.StartAsync(); var languageService = tester.PayTester.GetService(); // Most common format. First option does not have a quality score. Others do in descending order. // Result should be nl-NL (because the default weight is 1 for nl) var lang1 = languageService.FindLanguageInAcceptLanguageHeader("nl,fr;q=0.7,en;q=0.5"); Assert.NotNull(lang1); Assert.Equal("nl-NL", lang1?.Code); // Most common format. First option does not have a quality score. Others do in descending order. This time the first option includes a country. // Result should be nl-NL (because the default weight is 1 for nl-BE and it does not exist in BTCPay Server, but nl-NL does and applies too for language "nl") var lang2 = languageService.FindLanguageInAcceptLanguageHeader("nl-BE,fr;q=0.7,en;q=0.5"); Assert.NotNull(lang2); Assert.Equal("nl-NL", lang2?.Code); // Unusual format, but still valid. All values have a quality score and not ordered. // Result should be fr-FR (because 0.7 is the highest quality score) var lang3 = languageService.FindLanguageInAcceptLanguageHeader("nl;q=0.1,fr;q=0.7,en;q=0.5"); Assert.NotNull(lang3); Assert.Equal("fr-FR", lang3?.Code); // Unusual format, but still valid. Some language is given that we don't have and a wildcard for everything else. // Result should be NULL, because "xx" does not exist and * is a wildcard and has no meaning. var lang4 = languageService.FindLanguageInAcceptLanguageHeader("xx,*;q=0.5"); Assert.Null(lang4); } } }