Repository: Altinn/app-lib-dotnet
Pull request #1276: Use translation service for correspondence
Author: login: HauklandJ
Labels: name: ignore-for-release
name: kind/chore
name: other
description: A PR that should be in release notes, but as a chore
name: backport-ignore
description: This PR is a new feature and should not be cherry-picked onto release branches
Created: 2025-04-28T13:34:36Z | Merged: 2025-04-29T11:04:55Z

<!--- Provide a general summary of your changes in the Title above -->

## Description
Avhengig av https://github.com/Altinn/app-lib-dotnet/pull/1274

## Related Issue(s)
- #{issue number}

## Verification
- [ ] **Your** code builds clean without any errors or warnings
- [ ] Manual testing done (required)
- [ ] Relevant automated test added (if you find this hard, leave it and we'll help out)
- [ ] All tests run green

## Documentation
- [ ] User documentation is updated with a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs) (if applicable)



Diff:
diff --git a/src/Altinn.App.Core/Extensions/TextResourceExtensions.cs b/src/Altinn.App.Core/Extensions/TextResourceExtensions.cs
deleted file mode 100644
index e0c090d18..000000000
--- a/src/Altinn.App.Core/Extensions/TextResourceExtensions.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using Altinn.Platform.Storage.Interface.Models;
-
-namespace Altinn.App.Core.Extensions;
-
-internal static class TextResourceExtensions
-{
-    /// <summary>
-    /// Gets the value for the specified key from the text resource.
-    /// </summary>
-    /// <param name="resource">The text resource.</param>
-    /// <param name="key">The key to look up.</param>
-    /// <returns>The value if found; otherwise, null.</returns>
-    public static string? GetText(this TextResource resource, string? key)
-    {
-        if (key is null)
-        {
-            return null;
-        }
-        return resource.Resources?.FirstOrDefault(x => x.Id.Equals(key, StringComparison.Ordinal))?.Value;
-    }
-
-    /// <summary>
-    /// Gets the value for the first matching key from the text resource.
-    /// </summary>
-    /// <param name="resource">The text resource.</param>
-    /// <param name="keys">An array of keys to try in order.</param>
-    /// <returns>The first matching value if found; otherwise, null.</returns>
-    public static string? GetFirstMatchingText(this TextResource resource, params string[] keys)
-    {
-        foreach (var key in keys)
-        {
-            var value = resource.GetText(key);
-            if (value is not null)
-            {
-                return value;
-            }
-        }
-        return null;
-    }
-}
diff --git a/src/Altinn.App.Core/Features/Action/SigningUserAction.cs b/src/Altinn.App.Core/Features/Action/SigningUserAction.cs
index 242bd3ade..5f3d78f92 100644
--- a/src/Altinn.App.Core/Features/Action/SigningUserAction.cs
+++ b/src/Altinn.App.Core/Features/Action/SigningUserAction.cs
@@ -62,7 +62,7 @@ ILogger<SigningUserAction> logger
     public string Id => "sign";
 
     /// <inheritdoc />
-    /// <exception cref="Helpers.PlatformHttpException"></exception>
+    /// <exception cref="PlatformHttpException"></exception>
     /// <exception cref="ApplicationConfigException"></exception>
     public async Task<UserActionResult> HandleAction(UserActionContext context)
     {
diff --git a/src/Altinn.App.Core/Features/Signing/Services/SigningCallToActionService.cs b/src/Altinn.App.Core/Features/Signing/Services/SigningCallToActionService.cs
index 04517882e..2dabd51bc 100644
--- a/src/Altinn.App.Core/Features/Signing/Services/SigningCallToActionService.cs
+++ b/src/Altinn.App.Core/Features/Signing/Services/SigningCallToActionService.cs
@@ -1,7 +1,6 @@
 using Altinn.App.Core.Configuration;
 using Altinn.App.Core.Constants;
 using Altinn.App.Core.Exceptions;
-using Altinn.App.Core.Extensions;
 using Altinn.App.Core.Features.Correspondence;
 using Altinn.App.Core.Features.Correspondence.Builder;
 using Altinn.App.Core.Features.Correspondence.Models;
@@ -11,10 +10,10 @@
 using Altinn.App.Core.Internal.Language;
 using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
 using Altinn.App.Core.Internal.Profile;
+using Altinn.App.Core.Internal.Texts;
 using Altinn.App.Core.Models;
 using Altinn.Platform.Profile.Models;
 using Altinn.Platform.Register.Models;
-using Altinn.Platform.Storage.Interface.Models;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
@@ -24,16 +23,15 @@ namespace Altinn.App.Core.Features.Signing.Services;
 internal sealed class SigningCallToActionService(
     ICorrespondenceClient correspondenceClient,
     IHostEnvironment hostEnvironment,
-    IAppResources appResources,
     IAppMetadata appMetadata,
     IProfileClient profileClient,
+    ITranslationService translationService,
     ILogger<SigningCallToActionService> logger,
     IOptions<GeneralSettings> settings
 ) : ISigningCallToActionService
 {
     private readonly ICorrespondenceClient _correspondenceClient = correspondenceClient;
     private readonly IHostEnvironment _hostEnvironment = hostEnvironment;
-    private readonly IAppResources _appResources = appResources;
     private readonly IAppMetadata _appMetadata = appMetadata;
     private readonly IProfileClient _profileClient = profileClient;
     private readonly ILogger<SigningCallToActionService> _logger = logger;
@@ -234,7 +232,8 @@ internal static string GetLinkDisplayText(string language)
         {
             LanguageConst.Nn => "Klikk her for å opne skjema",
             LanguageConst.En => "Click here to open the form",
-            LanguageConst.Nb or _ => "Klikk her for å åpne skjema",
+            LanguageConst.Nb => "Klikk her for å åpne skjema",
+            _ => "Klikk her for å åpne skjema",
         };
     }
 
@@ -247,7 +246,6 @@ internal async Task<ContentWrapper> GetContent(
         string language
     )
     {
-        TextResource? textResource = null;
         string? correspondenceTitle = null;
         string? correspondenceSummary = null;
         string? correspondenceBody = null;
@@ -257,31 +255,41 @@ string language
         string? appName = null;
 
         string appOwner = senderParty.Name ?? appMetadata.Org;
-        string defaultAppName =
-            appMetadata.Title?.GetValueOrDefault(language)
-            ?? appMetadata.Title?.FirstOrDefault().Value
-            ?? appMetadata.Id;
 
         try
         {
-            textResource ??=
-                await _appResources.GetTexts(appIdentifier.Org, appIdentifier.App, language)
-                ?? throw new InvalidOperationException($"No text resource found for language ({language})");
-
             string linkDisplayText = GetLinkDisplayText(language);
-            correspondenceTitle = textResource.GetText(communicationConfig?.InboxMessage?.TitleTextResourceKey);
-            correspondenceSummary = textResource.GetText(communicationConfig?.InboxMessage?.SummaryTextResourceKey);
-            correspondenceBody = textResource.GetText(communicationConfig?.InboxMessage?.BodyTextResourceKey);
+            correspondenceTitle = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.InboxMessage?.TitleTextResourceKey,
+                language
+            );
+            correspondenceSummary = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.InboxMessage?.SummaryTextResourceKey,
+                language
+            );
+            correspondenceBody = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.InboxMessage?.BodyTextResourceKey,
+                language
+            );
             correspondenceBody = correspondenceBody?.Replace(
                 "$InstanceUrl",
                 $"[{linkDisplayText}]({instanceUrl})",
                 StringComparison.InvariantCultureIgnoreCase
             );
-            appName = textResource.GetFirstMatchingText("appName", "ServiceName");
 
-            smsBody = textResource.GetText(communicationConfig?.Notification?.Sms?.BodyTextResourceKey);
-            emailBody = textResource.GetText(communicationConfig?.Notification?.Email?.BodyTextResourceKey);
-            emailSubject = textResource.GetText(communicationConfig?.Notification?.Email?.SubjectTextResourceKey);
+            smsBody = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.Notification?.Sms?.BodyTextResourceKey,
+                language
+            );
+            emailBody = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.Notification?.Email?.BodyTextResourceKey,
+                language
+            );
+            emailSubject = await translationService.TranslateTextKeyLenient(
+                communicationConfig?.Notification?.Email?.SubjectTextResourceKey,
+                language
+            );
+            appName = await translationService.TranslateFirstMatchingTextKey(language, "appName", "ServiceName");
         }
         catch (Exception e)
         {
@@ -294,7 +302,10 @@ await _appResources.GetTexts(appIdentifier.Org, appIdentifier.App, language)
 
         if (string.IsNullOrWhiteSpace(appName))
         {
-            appName = defaultAppName;
+            appName =
+                appMetadata.Title?.GetValueOrDefault(language)
+                ?? appMetadata.Title?.FirstOrDefault().Value
+                ?? appMetadata.Id;
         }
 
         var defaults = GetDefaultTexts(instanceUrl, language, appName, appOwner);
@@ -302,7 +313,7 @@ await _appResources.GetTexts(appIdentifier.Org, appIdentifier.App, language)
         {
             CorrespondenceContent = new CorrespondenceContent()
             {
-                Language = LanguageCode<Iso6391>.Parse(textResource?.Language ?? language),
+                Language = LanguageCode<Iso6391>.Parse(language),
                 Title = correspondenceTitle ?? defaults.Title,
                 Summary = correspondenceSummary ?? defaults.Summary,
                 Body = correspondenceBody ?? defaults.Body,
diff --git a/src/Altinn.App.Core/Features/Signing/Services/SigningReceiptService.cs b/src/Altinn.App.Core/Features/Signing/Services/SigningReceiptService.cs
index e7a99e84c..29b6e586d 100644
--- a/src/Altinn.App.Core/Features/Signing/Services/SigningReceiptService.cs
+++ b/src/Altinn.App.Core/Features/Signing/Services/SigningReceiptService.cs
@@ -1,7 +1,6 @@
 using System.Globalization;
 using Altinn.App.Core.Constants;
 using Altinn.App.Core.Exceptions;
-using Altinn.App.Core.Extensions;
 using Altinn.App.Core.Features.Correspondence;
 using Altinn.App.Core.Features.Correspondence.Builder;
 using Altinn.App.Core.Features.Correspondence.Models;
@@ -12,6 +11,7 @@
 using Altinn.App.Core.Internal.Language;
 using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
 using Altinn.App.Core.Internal.Sign;
+using Altinn.App.Core.Internal.Texts;
 using Altinn.App.Core.Models;
 using Altinn.App.Core.Models.UserAction;
 using Altinn.Platform.Storage.Interface.Models;
@@ -24,21 +24,20 @@ internal sealed class SigningReceiptService(
     ICorrespondenceClient correspondenceClient,
     IDataClient dataClient,
     IHostEnvironment hostEnvironment,
-    IAppResources appResources,
     IAppMetadata appMetadata,
+    ITranslationService translationService,
     ILogger<SigningReceiptService> logger
 ) : ISigningReceiptService
 {
     private readonly ICorrespondenceClient _correspondenceClient = correspondenceClient;
     private readonly IDataClient _dataClient = dataClient;
     private readonly IHostEnvironment _hostEnvironment = hostEnvironment;
-    private readonly IAppResources _appResources = appResources;
     private readonly IAppMetadata _appMetadata = appMetadata;
     private readonly ILogger<SigningReceiptService> _logger = logger;
 
     public async Task<SendCorrespondenceResponse?> SendSignatureReceipt(
         InstanceIdentifier instanceIdentifier,
-        Altinn.App.Core.Internal.Sign.Signee signee,
+        Internal.Sign.Signee signee,
         IEnumerable<DataElementSignature> dataElementSignatures,
         UserActionContext context,
         List<AltinnEnvironmentConfig>? correspondenceResources
@@ -138,7 +137,6 @@ internal async Task<CorrespondenceContent> GetContent(
         AltinnCdnOrgDetails senderDetails
     )
     {
-        TextResource? textResource = null;
         string? title = null;
         string? summary = null;
         string? body = null;
@@ -151,20 +149,16 @@ AltinnCdnOrgDetails senderDetails
             ?? appMetadata.Title?.FirstOrDefault().Value
             ?? appMetadata.Id;
 
+        string language = context.Language ?? defaultLanguage;
+
         try
         {
             AppIdentifier appIdentifier = new(context.Instance);
 
-            textResource =
-                await _appResources.GetTexts(appIdentifier.Org, appIdentifier.App, context.Language ?? defaultLanguage)
-                ?? throw new InvalidOperationException(
-                    $"No text resource found for specified language ({context.Language}) nor the default language ({defaultLanguage})"
-                );
-
-            title = textResource.GetText("signing.correspondence_receipt_title");
-            summary = textResource.GetText("signing.correspondence_receipt_summary");
-            body = textResource.GetText("signing.correspondence_receipt_body");
-            appName = textResource.GetFirstMatchingText("appName", "ServiceName");
+            title = await translationService.TranslateTextKey("signing.correspondence_receipt_title", language);
+            summary = await translationService.TranslateTextKey("signing.correspondence_receipt_summary", language);
+            body = await translationService.TranslateTextKey("signing.correspondence_receipt_body", language);
+            appName = await translationService.TranslateFirstMatchingTextKey(language, "appName", "ServiceName");
         }
         catch (Exception e)
         {
@@ -180,11 +174,11 @@ await _appResources.GetTexts(appIdentifier.Org, appIdentifier.App, context.Langu
             appName = defaultAppName;
         }
 
-        var defaults = GetDefaultTexts(context.Language ?? defaultLanguage, appName, appOwner);
+        var defaults = GetDefaultTexts(language, appName, appOwner);
 
         CorrespondenceContent content = new()
         {
-            Language = LanguageCode<Iso6391>.Parse(textResource?.Language ?? defaultLanguage),
+            Language = LanguageCode<Iso6391>.Parse(language),
             Title = title ?? defaults.Title,
             Summary = summary ?? defaults.Summary,
             Body = body ?? defaults.Body,
diff --git a/src/Altinn.App.Core/Internal/Texts/ITranslationService.cs b/src/Altinn.App.Core/Internal/Texts/ITranslationService.cs
index 26edbec83..523d285c6 100644
--- a/src/Altinn.App.Core/Internal/Texts/ITranslationService.cs
+++ b/src/Altinn.App.Core/Internal/Texts/ITranslationService.cs
@@ -12,4 +12,21 @@ public interface ITranslationService
     /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
     /// <returns>The value of the text resource in the specified language</returns>
     Task<string> TranslateTextKey(string key, string? language);
+
+    /// <summary>
+    /// Get the translated value of a text resource
+    /// </summary>
+    /// <param name="key">Id of the text resource. If null, returns null.</param>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <returns>The value of the text resource in the specified language or null</returns>
+    /// <exception cref="ArgumentException">If the text resource with the specified key does not exist</exception>
+    Task<string?> TranslateTextKeyLenient(string? key, string? language);
+
+    /// <summary>
+    /// Get the first matching text resource value for the specified keys in the specified language.
+    /// </summary>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <param name="keys">Array of keys to search for</param>
+    /// <returns>The value of the first matching text resource in the specified language or null</returns>
+    Task<string?> TranslateFirstMatchingTextKey(string? language, params string[] keys);
 }
diff --git a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs
index 86e42df6f..352316580 100644
--- a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs
+++ b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs
@@ -28,28 +28,68 @@ public TranslationService(AppIdentifier appIdentifier, IAppResources appResource
     /// <param name="key">Id of the text resource</param>
     /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
     /// <returns>The value of the text resource in the specified language</returns>
+    /// <exception cref="ArgumentException">If the text resource with the specified key does not exist</exception>
     public async Task<string> TranslateTextKey(string key, string? language)
     {
-        language = language ?? LanguageConst.Nb;
+        language ??= LanguageConst.Nb;
         TextResource? textResource = await _appResources.GetTexts(_org, _app, language);
 
-        if (textResource == null && language != LanguageConst.Nb)
+        if (textResource is null && language != LanguageConst.Nb)
         {
             textResource = await _appResources.GetTexts(_org, _app, LanguageConst.Nb);
         }
 
-        if (textResource == null)
+        if (textResource is null)
         {
             throw new ArgumentException($"Could not locate text resource file with language = \"{language}\"");
         }
 
-        var value = textResource.Resources.Find(resource => resource.Id == key)?.Value;
+        var value =
+            (textResource.Resources.Find(resource => resource.Id == key)?.Value)
+            ?? throw new ArgumentException($"Text resource with id = {key} does not exist");
+        return value;
+    }
 
-        if (value == null)
+    /// <summary>
+    /// Get the first matching text resource value for the specified keys in the specified language.
+    /// </summary>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <param name="keys">Array of keys to search for</param>
+    /// <returns>The value of the first matching text resource in the specified language or null</returns>
+    public async Task<string?> TranslateFirstMatchingTextKey(string? language, params string[] keys)
+    {
+        language ??= LanguageConst.Nb;
+        foreach (var key in keys)
         {
-            throw new ArgumentException($"Text resource with id = {key} does not exist");
+            TextResource? textResource = await _appResources.GetTexts(_org, _app, language);
+
+            if (textResource is null && language != LanguageConst.Nb)
+            {
+                textResource = await _appResources.GetTexts(_org, _app, LanguageConst.Nb);
+            }
+            var value = textResource?.Resources.Find(resource => resource.Id == key)?.Value;
+            if (value is not null)
+            {
+                return value;
+            }
         }
+        return null;
+    }
 
-        return value;
+    /// <summary>
+    /// Get the translated value of a text resource
+    /// </summary>
+    /// <param name="key">Id of the text resource. If null, returns null.</param>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <returns>The value of the text resource in the specified language or null</returns>
+    /// <exception cref="ArgumentException">If the text resource with the specified key does not exist</exception>
+    public async Task<string?> TranslateTextKeyLenient(string? key, string? language)
+    {
+        if (string.IsNullOrEmpty(key))
+        {
+            return null;
+        }
+
+        return await TranslateTextKey(key, language);
     }
 }
diff --git a/test/Altinn.App.Core.Tests/Extensions/TextResourceExtensionsTests.cs b/test/Altinn.App.Core.Tests/Extensions/TextResourceExtensionsTests.cs
deleted file mode 100644
index 4c6239eb6..000000000
--- a/test/Altinn.App.Core.Tests/Extensions/TextResourceExtensionsTests.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using Altinn.App.Core.Extensions;
-using Altinn.Platform.Storage.Interface.Models;
-
-namespace Altinn.App.Core.Tests.Extensions;
-
-public class TextResourceExtensionsTests
-{
-    [Fact]
-    public void GetTextResource_ReturnsCorrectTextResource()
-    {
-        // Arrange
-        string textResourceKey = "TestKey";
-        string expected = "Test text resource";
-
-        TextResource textResource = new()
-        {
-            Resources = [new TextResourceElement { Id = textResourceKey, Value = expected }],
-        };
-
-        // Act
-        string? result = textResource.GetText(textResourceKey);
-
-        // Assert
-        Assert.NotNull(result);
-        Assert.Equal(expected, result);
-    }
-
-    [Fact]
-    public void GetTextResource_ReturnsNullWhenKeyIsNull()
-    {
-        // Arrange
-        string textResourceKey = "TestKey";
-        string expected = "Test text resource";
-
-        TextResource textResource = new()
-        {
-            Resources = [new TextResourceElement { Id = textResourceKey, Value = expected }],
-        };
-
-        // Act
-        string? result = textResource.GetText(null);
-
-        // Assert
-        Assert.Null(result);
-    }
-
-    [Fact]
-    public void GetFirstMatchingText_SingleMatch_ReturnsCorrectTextResource()
-    {
-        // Arrange
-        string textResourceKey = "TestKey";
-        string expected = "Test text resource";
-
-        TextResource textResource = new()
-        {
-            Resources = [new TextResourceElement { Id = textResourceKey, Value = expected }],
-        };
-
-        // Act
-        string? result = textResource.GetFirstMatchingText("NonExistingKey", textResourceKey);
-
-        // Assert
-        Assert.NotNull(result);
-        Assert.Equal(expected, result);
-    }
-
-    [Fact]
-    public void GetFirstMatchingText_MultipleMatches_ReturnsFirstMatch()
-    {
-        // Arrange
-        string textResourceKey = "TestKey";
-        string anotherTextResourceKey = "AnotherTestKey";
-        string expected = "Test text resource";
-
-        TextResource textResource = new()
-        {
-            Resources =
-            [
-                new TextResourceElement { Id = anotherTextResourceKey, Value = "Another test text resource" },
-                new TextResourceElement { Id = textResourceKey, Value = expected },
-            ],
-        };
-
-        // Act
-        string? result = textResource.GetFirstMatchingText("NonExistingKey", textResourceKey, anotherTextResourceKey);
-
-        // Assert
-        Assert.NotNull(result);
-        Assert.Equal(expected, result);
-    }
-}
diff --git a/test/Altinn.App.Core.Tests/Features/Signing/SigningCallToActionServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Signing/SigningCallToActionServiceTests.cs
index 33c108a15..9a2803cc6 100644
--- a/test/Altinn.App.Core.Tests/Features/Signing/SigningCallToActionServiceTests.cs
+++ b/test/Altinn.App.Core.Tests/Features/Signing/SigningCallToActionServiceTests.cs
@@ -9,6 +9,7 @@
 using Altinn.App.Core.Internal.Language;
 using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
 using Altinn.App.Core.Internal.Profile;
+using Altinn.App.Core.Internal.Texts;
 using Altinn.App.Core.Models;
 using Altinn.Platform.Register.Models;
 using Altinn.Platform.Storage.Interface.Models;
@@ -34,23 +35,23 @@ private static NationalIdentityNumber GetSsn(int index) =>
     private SigningCallToActionService SetupService(
         Mock<ICorrespondenceClient>? correspondenceClientMockOverride = null,
         Mock<IHostEnvironment>? hostEnvironmentMockOverride = null,
-        Mock<IAppResources>? appResourcesMockOverride = null,
         Mock<IAppMetadata>? appMetadataMockOverride = null,
-        Mock<IProfileClient>? profileClientMockOverride = null
+        Mock<IProfileClient>? profileClientMockOverride = null,
+        ITranslationService? translationServiceOverride = null
     )
     {
         Mock<ICorrespondenceClient> correspondenceClientMock = correspondenceClientMockOverride ?? new();
         Mock<IHostEnvironment> hostEnvironmentMock = hostEnvironmentMockOverride ?? new();
-        Mock<IAppResources> appResourcesMock = appResourcesMockOverride ?? new();
         Mock<IAppMetadata> appMetadataMock = appMetadataMockOverride ?? new();
+        Mock<ITranslationService> translationServiceMock = new();
         Mock<IProfileClient> profileClientMock = profileClientMockOverride ?? new();
         Mock<ILogger<SigningCallToActionService>> loggerMock = new();
         return new SigningCallToActionService(
             correspondenceClientMock.Object,
             hostEnvironmentMock.Object,
-            appResourcesMock.Object,
             appMetadataMock.Object,
             profileClientMock.Object,
+            translationServiceOverride ?? translationServiceMock.Object,
             loggerMock.Object,
             _generalSettings
         );
@@ -98,9 +99,12 @@ public async Task SendSignCallToAction_SmsNotificationConfigured_CallsCorrespond
         Mock<IAppMetadata> appMetadataMock = new();
         appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(applicationMetadata);
 
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
         SigningCallToActionService service = SetupService(
             correspondenceClientMockOverride: correspondenceClientMock,
-            appResourcesMockOverride: appResourcesMock,
+            translationServiceOverride: translationService,
             appMetadataMockOverride: appMetadataMock,
             hostEnvironmentMockOverride: hostEnvironmentMock
         );
@@ -113,7 +117,6 @@ public async Task SendSignCallToAction_SmsNotificationConfigured_CallsCorrespond
             },
         };
 
-        AppIdentifier appIdentifier = new("org", "app");
         InstanceIdentifier instanceIdentifier = new(123, Guid.Parse("ab0cdeb5-dc5e-4faa-966b-d18bb932ca07"));
 
         var orgNo = GetOrgNumber(1);
@@ -179,9 +182,12 @@ public async Task SendSignCallToAction_EmailNotificationConfigured_CallsCorrespo
         Mock<IAppMetadata> appMetadataMock = new();
         appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(applicationMetadata);
 
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
         SigningCallToActionService service = SetupService(
             correspondenceClientMockOverride: correspondenceClientMock,
-            appResourcesMockOverride: appResourcesMock,
+            translationServiceOverride: translationService,
             appMetadataMockOverride: appMetadataMock,
             hostEnvironmentMockOverride: hostEnvironmentMock
         );
@@ -198,8 +204,6 @@ public async Task SendSignCallToAction_EmailNotificationConfigured_CallsCorrespo
                 },
             },
         };
-
-        AppIdentifier appIdentifier = new("org", "app");
         InstanceIdentifier instanceIdentifier = new(123, Guid.Parse("ab0cdeb5-dc5e-4faa-966b-d18bb932ca07"));
 
         var orgNo = GetOrgNumber(10);
@@ -274,9 +278,12 @@ public async Task SendSignCallToAction_AllCustomcommunicationConfig_CallsCorresp
         Mock<IAppMetadata> appMetadataMock = new();
         appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(applicationMetadata);
 
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
         SigningCallToActionService service = SetupService(
             correspondenceClientMockOverride: correspondenceClientMock,
-            appResourcesMockOverride: appResourcesMock,
+            translationServiceOverride: translationService,
             appMetadataMockOverride: appMetadataMock,
             hostEnvironmentMockOverride: hostEnvironmentMock
         );
@@ -300,7 +307,6 @@ public async Task SendSignCallToAction_AllCustomcommunicationConfig_CallsCorresp
                 },
             },
         };
-        AppIdentifier appIdentifier = new("org", "app");
         InstanceIdentifier instanceIdentifier = new(123, Guid.Parse("ab0cdeb5-dc5e-4faa-966b-d18bb932ca07"));
 
         var orgNo = GetOrgNumber(4);
@@ -367,15 +373,17 @@ public async Task SendSignCallToAction_NotificationNotConfigured_CallsCorrespond
         Mock<IAppMetadata> appMetadataMock = new();
         appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(applicationMetadata);
 
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
         SigningCallToActionService service = SetupService(
             correspondenceClientMockOverride: correspondenceClientMock,
-            appResourcesMockOverride: appResourcesMock,
+            translationServiceOverride: translationService,
             appMetadataMockOverride: appMetadataMock,
             hostEnvironmentMockOverride: hostEnvironmentMock
         );
 
-        CommunicationConfig communicationConfig = new() { Notification = new Notification { } };
-        AppIdentifier appIdentifier = new("org", "app");
+        CommunicationConfig communicationConfig = new() { Notification = new() { } };
         InstanceIdentifier instanceIdentifier = new(123, Guid.Parse("ab0cdeb5-dc5e-4faa-966b-d18bb932ca07"));
 
         var orgNo = GetOrgNumber(1);
@@ -448,15 +456,17 @@ public async Task SendSignCallToAction_ResourceNotFound_ThrowsException()
         Mock<IAppMetadata> appMetadataMock = new();
         appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(applicationMetadata);
 
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
         SigningCallToActionService service = SetupService(
             correspondenceClientMockOverride: correspondenceClientMock,
-            appResourcesMockOverride: appResourcesMock,
+            translationServiceOverride: translationService,
             appMetadataMockOverride: appMetadataMock,
             hostEnvironmentMockOverride: hostEnvironmentMock
         );
 
         CommunicationConfig communicationConfig = new() { Notification = new Notification { } };
-        AppIdentifier appIdentifier = new("org", "app");
         InstanceIdentifier instanceIdentifier = new(123, Guid.Parse("ab0cdeb5-dc5e-4faa-966b-d18bb932ca07"));
         Party signingParty = new() { Name = "Signee", SSN = GetSsn(1) };
         Party serviceOwnerParty = new() { Name = "Service owner", OrgNumber = GetOrgNumber(1) };
@@ -489,16 +499,18 @@ public async Task GetContent_WithCustomTexts_ReturnsCorrectContent()
             new() { Id = smsContentTextResourceKey, Value = "Custom sms content" },
         ];
         Mock<IAppResources> mock = SetupAppResourcesMock(additionalTextResourceElements: smsTextResource);
-        SigningCallToActionService service = SetupService(appResourcesMockOverride: mock);
 
         CommunicationConfig communicationConfig = new()
         {
-            Notification = new()
+            Notification = new Notification
             {
                 Sms = new Sms { MobileNumber = "12345678", BodyTextResourceKey = smsContentTextResourceKey },
             },
         };
         AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, mock.Object);
+        SigningCallToActionService service = SetupService(translationServiceOverride: translationService);
+
         ApplicationMetadata applicationMetadata = new("org/app")
         {
             Title = new Dictionary<string, string> { { LanguageConst.En, "App name" } },
@@ -533,7 +545,6 @@ public async Task GetContent_GetTextsThrows_UsesDefaultTexts()
         Mock<IAppResources> mock = SetupAppResourcesMock();
         mock.Setup(m => m.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
             .ThrowsAsync(new Exception());
-        SigningCallToActionService service = SetupService(appResourcesMockOverride: mock);
 
         CommunicationConfig communicationConfig = new()
         {
@@ -543,6 +554,9 @@ public async Task GetContent_GetTextsThrows_UsesDefaultTexts()
             },
         };
         AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, mock.Object);
+        SigningCallToActionService service = SetupService(translationServiceOverride: translationService);
+
         ApplicationMetadata applicationMetadata = new("org/app")
         {
             Title = new Dictionary<string, string> { { LanguageConst.En, "App name" } },
diff --git a/test/Altinn.App.Core.Tests/Features/Signing/SigningReceiptServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Signing/SigningReceiptServiceTests.cs
index 8996d2455..174309890 100644
--- a/test/Altinn.App.Core.Tests/Features/Signing/SigningReceiptServiceTests.cs
+++ b/test/Altinn.App.Core.Tests/Features/Signing/SigningReceiptServiceTests.cs
@@ -10,6 +10,7 @@
 using Altinn.App.Core.Internal.Language;
 using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
 using Altinn.App.Core.Internal.Sign;
+using Altinn.App.Core.Internal.Texts;
 using Altinn.App.Core.Models;
 using Altinn.App.Core.Models.UserAction;
 using Altinn.Platform.Storage.Interface.Models;
@@ -25,22 +26,22 @@ static SigningReceiptService SetupService(
         Mock<ICorrespondenceClient>? correspondenceClientMockOverride = null,
         Mock<IHostEnvironment>? hostEnvironmentMockOverride = null,
         Mock<IDataClient>? dataClientMockOverride = null,
-        Mock<IAppResources>? appResourcesMockOverride = null,
-        Mock<IAppMetadata>? appMetadataMockOverride = null
+        Mock<IAppMetadata>? appMetadataMockOverride = null,
+        ITranslationService? translationServiceOverride = null
     )
     {
         Mock<ICorrespondenceClient> correspondenceClientMock = correspondenceClientMockOverride ?? new();
         Mock<IHostEnvironment> hostEnvironmentMock = hostEnvironmentMockOverride ?? new();
         Mock<IDataClient>? dataClientMock = dataClientMockOverride ?? new();
-        Mock<IAppResources> appResourcesMock = appResourcesMockOverride ?? new();
         Mock<IAppMetadata> appMetadataMock = appMetadataMockOverride ?? new();
+        Mock<ITranslationService> translationServiceMock = new();
         Mock<ILogger<SigningReceiptService>> loggerMock = new();
         return new SigningReceiptService(
             correspondenceClientMock.Object,
             dataClientMock.Object,
             hostEnvironmentMock.Object,
-            appResourcesMock.Object,
             appMetadataMock.Object,
+            translationServiceOverride ?? translationServiceMock.Object,
             loggerMock.Object
         );
     }
@@ -246,7 +247,10 @@ public async Task GetContent_WithCustomTexts_ReturnsCustomContent()
             additionalTextResourceElements: textResourceElements
         );
 
-        var service = SetupService(appResourcesMockOverride: appResourcesMock);
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
+        var service = SetupService(translationServiceOverride: translationService);
 
         Instance instance = new() { Id = "org/app" };
 
@@ -293,7 +297,10 @@ public async Task GetContent_GetTextsThrows_UsesDefaultTexts()
             .Setup(r => r.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
             .ThrowsAsync(new Exception("Test exception"));
 
-        var service = SetupService(appResourcesMockOverride: appResourcesMock);
+        AppIdentifier appIdentifier = new("org", "app");
+        TranslationService translationService = new(appIdentifier, appResourcesMock.Object);
+
+        var service = SetupService(translationServiceOverride: translationService);
 
         Instance instance = new() { Id = "org/app" };
 
diff --git a/test/Altinn.App.Core.Tests/Internal/Texts/TranslationServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Texts/TranslationServiceTests.cs
index bdbf7cf1c..9abcdf4a3 100644
--- a/test/Altinn.App.Core.Tests/Internal/Texts/TranslationServiceTests.cs
+++ b/test/Altinn.App.Core.Tests/Internal/Texts/TranslationServiceTests.cs
@@ -1,10 +1,11 @@
 using Altinn.App.Core.Internal.App;
+using Altinn.App.Core.Internal.Language;
 using Altinn.App.Core.Internal.Texts;
 using Altinn.App.Core.Models;
 using Altinn.Platform.Storage.Interface.Models;
 using Moq;
 
-namespace Altinn.App.Core.Tests.Internal;
+namespace Altinn.App.Core.Tests.Internal.Texts;
 
 public class TranslationServiceTests
 {
@@ -15,68 +16,108 @@ public TranslationServiceTests()
     {
         _appResourcesMock
             .Setup(appResources =>
-                appResources.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.Is<string>(s => s == "nb"))
+                appResources.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.Is<string>(s => s == LanguageConst.Nb))
             )
             .ReturnsAsync(
                 new TextResource
                 {
-                    Resources = new List<TextResourceElement>()
-                    {
+                    Resources =
+                    [
                         new TextResourceElement { Id = "text", Value = "bokmål" },
-                    },
+                        new TextResourceElement { Id = "text2", Value = "bokmål2" },
+                    ],
                 }
             );
 
         _appResourcesMock
             .Setup(appResources =>
-                appResources.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.Is<string>(s => s == "en"))
+                appResources.GetTexts(It.IsAny<string>(), It.IsAny<string>(), It.Is<string>(s => s == LanguageConst.En))
             )
             .ReturnsAsync(
-                new TextResource
-                {
-                    Resources = new List<TextResourceElement>()
-                    {
-                        new TextResourceElement { Id = "text", Value = "english" },
-                    },
-                }
+                new TextResource { Resources = [new TextResourceElement { Id = "text", Value = "english" }] }
             );
 
         _translationService = new TranslationService(new AppIdentifier("org", "app"), _appResourcesMock.Object);
     }
 
     [Fact]
-    public async Task Returns_Nb()
+    public async Task TranslateTextKey_Returns_Nb()
     {
-        var result = await _translationService.TranslateTextKey("text", "nb");
+        var result = await _translationService.TranslateTextKey("text", LanguageConst.Nb);
         Assert.Equal("bokmål", result);
     }
 
     [Fact]
-    public async Task Returns_En()
+    public async Task TranslateTextKey_Returns_En()
     {
-        var result = await _translationService.TranslateTextKey("text", "en");
+        var result = await _translationService.TranslateTextKey("text", LanguageConst.En);
         Assert.Equal("english", result);
     }
 
     [Fact]
-    public async Task Default_Nb()
+    public async Task TranslateTextKey_Default_Nb()
     {
         var result = await _translationService.TranslateTextKey("text", null);
         Assert.Equal("bokmål", result);
     }
 
     [Fact]
-    public async Task Fallback_Nb()
+    public async Task TranslateTextKey_Fallback_Nb()
     {
-        var result = await _translationService.TranslateTextKey("text", "nn");
+        var result = await _translationService.TranslateTextKey("text", LanguageConst.Nn);
         Assert.Equal("bokmål", result);
     }
 
     [Fact]
-    public async Task Fail_Missing()
+    public async Task TranslateTextKey_Fail_Missing()
     {
         await Assert.ThrowsAsync<ArgumentException>(
-            async () => await _translationService.TranslateTextKey("missing", "nb")
+            async () => await _translationService.TranslateTextKey("missing", LanguageConst.Nb)
+        );
+    }
+
+    [Fact]
+    public async Task TranslateTextKeyLenient_Returns_Null_If_Key_Is_Null()
+    {
+        var result = await _translationService.TranslateTextKeyLenient(null, LanguageConst.Nb);
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task TranslateFirstMatchingTextKey_Returns_First_Match()
+    {
+        var result = await _translationService.TranslateFirstMatchingTextKey(
+            LanguageConst.Nb,
+            "missing",
+            "text2",
+            "text"
         );
+        Assert.Equal("bokmål2", result);
+    }
+
+    [Fact]
+    public async Task TranslateFirstMatchingTextKey_Returns_Null_If_No_Match()
+    {
+        var result = await _translationService.TranslateFirstMatchingTextKey(
+            LanguageConst.Nb,
+            "missing",
+            "missing2",
+            "missing3"
+        );
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task TranslateFirstMatchingTextKey_Default_Nb()
+    {
+        var result = await _translationService.TranslateFirstMatchingTextKey(null, "text");
+        Assert.Equal("bokmål", result);
+    }
+
+    [Fact]
+    public async Task TranslateFirstMatchingTextKey_Fallback_Nb()
+    {
+        var result = await _translationService.TranslateFirstMatchingTextKey(LanguageConst.Nn, "text");
+        Assert.Equal("bokmål", result);
     }
 }


Reviews:
user: login: bjorntore
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:52:49Z
user: login: bjorntore
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:55:17Z
user: login: HauklandJ
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:58:12Z

Review comments:
diff_hunk: @@ -12,4 +12,21 @@ public interface ITranslationService
     /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
     /// <returns>The value of the text resource in the specified language</returns>
     Task<string> TranslateTextKey(string key, string? language);
+
+    /// <summary>
+    /// Get the translated value of a text resource
+    /// </summary>
+    /// <param name="key">Id of the text resource. If null, returns null.</param>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <returns>The value of the text resource in the specified language or null</returns>
+    /// <exception cref="ArgumentException">If the text resource with the specified key does not exist</exception>
path: src/Altinn.App.Core/Internal/Texts/ITranslationService.cs
user: login: bjorntore
body: Probably doesn't throw argument exception, since it allows empty key?
created_at: 2025-04-29T10:52:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,4 +12,21 @@ public interface ITranslationService
     /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
     /// <returns>The value of the text resource in the specified language</returns>
     Task<string> TranslateTextKey(string key, string? language);
+
+    /// <summary>
+    /// Get the translated value of a text resource
+    /// </summary>
+    /// <param name="key">Id of the text resource. If null, returns null.</param>
+    /// <param name="language">Language for the text. If omitted, 'nb' will be used</param>
+    /// <returns>The value of the text resource in the specified language or null</returns>
+    /// <exception cref="ArgumentException">If the text resource with the specified key does not exist</exception>
path: src/Altinn.App.Core/Internal/Texts/ITranslationService.cs
user: login: HauklandJ
body: Throws if key is not null && key is not found
created_at: 2025-04-29T10:58:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-29T10:49:52Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Altinn_app-lib-dotnet&pullRequest=1276) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&pullRequest=1276&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&pullRequest=1276&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Altinn_app-lib-dotnet&pullRequest=1276&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [85.3% Coverage on New Code](https://sonarcloud.io/component_measures?id=Altinn_app-lib-dotnet&pullRequest=1276&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Altinn_app-lib-dotnet&pullRequest=1276&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Altinn_app-lib-dotnet&pullRequest=1276)


author_association: NONE