Skip to content

Commit 9419fbc

Browse files
authored
Merge pull request #996 from microting/fix/calendar-translation-languageid-remap
fix(calendar): blank task titles from invalid translation LanguageId
2 parents 63c9206 + b01c4f1 commit 9419fbc

7 files changed

Lines changed: 478 additions & 29 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2007 - 2026 Microting A/S
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction.
9+
*/
10+
11+
namespace BackendConfiguration.Pn.Integration.Test;
12+
13+
using BackendConfiguration.Pn.Infrastructure.Helpers;
14+
using Microsoft.EntityFrameworkCore;
15+
using Microsoft.Extensions.Logging.Abstractions;
16+
using Microting.eForm.Infrastructure.Data.Entities;
17+
using Microting.eForm.Infrastructure.Models;
18+
19+
/// <summary>
20+
/// Part A regression coverage for the calendar translation LanguageId bug. The
21+
/// create/edit modal hardcoded the Danish source title's LanguageId to the frontend
22+
/// app-locale id 1, while target languages already carry the real SDK Languages.Id
23+
/// from GET /settings/languages. The SDK Languages table is customer-specific
24+
/// (Danish is NOT guaranteed to be id 1, and the ids are NOT guaranteed to be the
25+
/// contiguous set {1,2,3}), so a verbatim persist stores the Danish title under a
26+
/// language no reader queries, producing a blank event title.
27+
///
28+
/// <see cref="AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync"/> must
29+
/// be EXISTENCE-based: only an id that is ABSENT from the SDK Languages table is
30+
/// remapped (via the static app-locale map) to the SDK Languages.Id that carries the
31+
/// intended LanguageCode. Any id that EXISTS in SDK Languages is a real target id and
32+
/// must be left untouched — even when it is 2, 3 or 4 — and unresolvable translates
33+
/// are never dropped.
34+
///
35+
/// These tests reproduce a SHIFTED-ID tenant (da=2, en-US=3, de-DE=4, NO id 1) to
36+
/// prove the safety property: a valid target id such as en-US=3 must NOT be reinterpreted
37+
/// via the static map (which historically mis-keyed it to de-DE), which was the corruption
38+
/// bug this change fixes.
39+
/// </summary>
40+
[Parallelizable(ParallelScope.Fixtures)]
41+
[TestFixture]
42+
public class CalendarTranslationLanguageIdRemapTests : TestBaseSetup
43+
{
44+
/// <summary>
45+
/// Reproduces a shifted-id tenant: removes the default-seeded da/en-US/de-DE rows,
46+
/// consumes id 1 with a filler so it is ABSENT for the language codes, then re-inserts
47+
/// da, en-US and de-DE so they land on ascending ids &gt; 1 (typically 2, 3, 4). Returns
48+
/// the resolved (daId, enUsId, deDeId). Guarantees no SDK Languages row has id 1.
49+
/// </summary>
50+
private async Task<(int DaId, int EnUsId, int DeDeId)> ForceShiftedIdTenant()
51+
{
52+
var codes = new[] { "da", "en-US", "de-DE" };
53+
var existing = await MicrotingDbContext!.Languages
54+
.Where(x => codes.Contains(x.LanguageCode))
55+
.ToListAsync();
56+
if (existing.Count > 0)
57+
{
58+
MicrotingDbContext.Languages.RemoveRange(existing);
59+
await MicrotingDbContext.SaveChangesAsync();
60+
}
61+
62+
// Consume id 1 with a filler so that no language code resolves to id 1.
63+
await MicrotingDbContext.Languages.AddAsync(
64+
new Language { Name = "Filler", LanguageCode = "zz-filler", IsActive = false });
65+
await MicrotingDbContext.SaveChangesAsync();
66+
67+
var da = new Language { Name = "Dansk", LanguageCode = "da", IsActive = true };
68+
await MicrotingDbContext.Languages.AddAsync(da);
69+
await MicrotingDbContext.SaveChangesAsync();
70+
71+
var enUs = new Language { Name = "English", LanguageCode = "en-US", IsActive = true };
72+
await MicrotingDbContext.Languages.AddAsync(enUs);
73+
await MicrotingDbContext.SaveChangesAsync();
74+
75+
var deDe = new Language { Name = "Deutsch", LanguageCode = "de-DE", IsActive = true };
76+
await MicrotingDbContext.Languages.AddAsync(deDe);
77+
await MicrotingDbContext.SaveChangesAsync();
78+
79+
// The existence-based remap keys purely off whether the incoming id is present in
80+
// SDK Languages, so id 1 must be absent from the WHOLE table for app-locale id 1 to
81+
// be treated as the (absent) hardcoded source. The default seed put da on id 1, which
82+
// we deleted; auto-increment never reuses it, so id 1 is now free.
83+
var hasIdOne = await MicrotingDbContext.Languages.AnyAsync(x => x.Id == 1);
84+
Assert.That(hasIdOne, Is.False,
85+
"Test precondition: no SDK Languages row may sit on id 1 (shifted-id tenant)");
86+
87+
return (da.Id, enUs.Id, deDe.Id);
88+
}
89+
90+
[Test]
91+
public async Task Remap_AbsentAppLocaleId1_IsRemappedToRealSdkDanishId()
92+
{
93+
var (daId, _, _) = await ForceShiftedIdTenant();
94+
Assert.That(daId, Is.Not.EqualTo(1),
95+
"Test precondition: SDK Danish id must differ from app-locale id 1 to reproduce the bug");
96+
97+
var translates = new List<CommonTranslationsModel>
98+
{
99+
// Danish source: the frontend formerly hardcoded app-locale id 1, which is
100+
// ABSENT from this tenant's SDK Languages and must be remapped to da's real id.
101+
new() { LanguageId = 1, Name = "Tank 4 inspektion", Description = "Kontrollér ventiler." },
102+
};
103+
104+
await AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync(
105+
translates, MicrotingDbContext!, NullLogger.Instance);
106+
107+
Assert.That(translates[0].LanguageId, Is.EqualTo(daId),
108+
"Absent app-locale id 1 must be remapped to the real SDK Danish Languages.Id");
109+
Assert.That(translates[0].Name, Is.EqualTo("Tank 4 inspektion"),
110+
"Remap must not mutate the translation text");
111+
}
112+
113+
[Test]
114+
public async Task Remap_ValidEnUsSdkId_IsLeftUnchanged_NoCorruption()
115+
{
116+
// Regression guard for the corruption bug: a valid en-US SDK id collides with
117+
// the static app-locale map's en-US slot (2) only by accident; the OLD value-based
118+
// guard remapped any present id 1/2/3 whose SDK code mismatched the static code,
119+
// corrupting good targets. The existence-based guard must leave any present id alone.
120+
var (_, enUsId, deDeId) = await ForceShiftedIdTenant();
121+
122+
var translates = new List<CommonTranslationsModel>
123+
{
124+
new() { LanguageId = enUsId, Name = "English title" },
125+
};
126+
127+
await AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync(
128+
translates, MicrotingDbContext!, NullLogger.Instance);
129+
130+
Assert.That(translates[0].LanguageId, Is.EqualTo(enUsId),
131+
"A valid SDK en-US id must be left unchanged — never reinterpreted via the static map");
132+
Assert.That(translates[0].LanguageId, Is.Not.EqualTo(deDeId),
133+
"The corruption bug remapped a valid en-US id onto de-DE; this must not happen");
134+
}
135+
136+
[Test]
137+
public async Task Remap_ValidDeDeSdkId_IsLeftUnchanged()
138+
{
139+
var (_, _, deDeId) = await ForceShiftedIdTenant();
140+
141+
var translates = new List<CommonTranslationsModel>
142+
{
143+
new() { LanguageId = deDeId, Name = "Deutscher Titel" },
144+
};
145+
146+
await AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync(
147+
translates, MicrotingDbContext!, NullLogger.Instance);
148+
149+
Assert.That(translates[0].LanguageId, Is.EqualTo(deDeId),
150+
"A valid SDK de-DE id must be left unchanged");
151+
}
152+
153+
[Test]
154+
public async Task Remap_MixedSourceAndTargets_OnShiftedTenant_OnlyAbsentIdRemapped()
155+
{
156+
var (daId, enUsId, deDeId) = await ForceShiftedIdTenant();
157+
158+
var translates = new List<CommonTranslationsModel>
159+
{
160+
// Danish source still arrives as the absent app-locale id 1.
161+
new() { LanguageId = 1, Name = "Dansk titel" },
162+
// Targets already carry their true SDK ids (from getLanguages()).
163+
new() { LanguageId = enUsId, Name = "English title" },
164+
new() { LanguageId = deDeId, Name = "Deutscher Titel" },
165+
};
166+
167+
await AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync(
168+
translates, MicrotingDbContext!, NullLogger.Instance);
169+
170+
Assert.Multiple(() =>
171+
{
172+
Assert.That(translates[0].LanguageId, Is.EqualTo(daId),
173+
"Absent Danish app-locale id 1 must be remapped to the SDK Danish id");
174+
Assert.That(translates[1].LanguageId, Is.EqualTo(enUsId),
175+
"A translate that already carries a valid SDK en-US id must be left untouched");
176+
Assert.That(translates[2].LanguageId, Is.EqualTo(deDeId),
177+
"A translate that already carries a valid SDK de-DE id must be left untouched");
178+
});
179+
}
180+
181+
[Test]
182+
public async Task Remap_AbsentIdNotInStaticMap_IsLeftUnchanged_AndTranslationNotDropped()
183+
{
184+
await ForceShiftedIdTenant();
185+
186+
// 9999 is neither a known app-locale id nor a valid SDK id.
187+
var translates = new List<CommonTranslationsModel>
188+
{
189+
new() { LanguageId = 9999, Name = "Untouched" },
190+
};
191+
192+
await AreaRuleLanguageHelper.RemapCommonTranslationLanguageIdsAsync(
193+
translates, MicrotingDbContext!, NullLogger.Instance);
194+
195+
Assert.Multiple(() =>
196+
{
197+
Assert.That(translates, Has.Count.EqualTo(1),
198+
"An unresolvable translate must never be dropped");
199+
Assert.That(translates[0].LanguageId, Is.EqualTo(9999),
200+
"An absent id with no static-map entry must be left unchanged");
201+
});
202+
}
203+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using System.Collections.Generic;
2+
using BackendConfiguration.Pn.Services.BackendConfigurationCalendarService;
3+
using Microting.EformBackendConfigurationBase.Infrastructure.Data.Entities;
4+
using NUnit.Framework;
5+
6+
namespace BackendConfiguration.Pn.Test.Services;
7+
8+
[TestFixture]
9+
public class ResolveTaskTitleTests
10+
{
11+
private static AreaRuleTranslation T(int languageId, string name) =>
12+
new() { LanguageId = languageId, Name = name };
13+
14+
[Test]
15+
public void UserLanguageNonEmpty_Wins()
16+
{
17+
var translations = new List<AreaRuleTranslation>
18+
{
19+
T(1, "English"),
20+
T(2, "Dansk")
21+
};
22+
23+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, null);
24+
25+
Assert.That(title, Is.EqualTo("Dansk"));
26+
}
27+
28+
[Test]
29+
public void UserLanguageEmptyString_FallsBackToOtherLanguage()
30+
{
31+
// Reproduces the original `??`-only bug: an empty-string name in the
32+
// user's language must NOT win; we must fall through to another row.
33+
var translations = new List<AreaRuleTranslation>
34+
{
35+
T(1, "English"),
36+
T(2, "")
37+
};
38+
39+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, null);
40+
41+
Assert.That(title, Is.EqualTo("English"));
42+
}
43+
44+
[Test]
45+
public void UserLanguageWhitespace_FallsBackToOtherLanguage()
46+
{
47+
var translations = new List<AreaRuleTranslation>
48+
{
49+
T(1, "English"),
50+
T(2, " ")
51+
};
52+
53+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, null);
54+
55+
Assert.That(title, Is.EqualTo("English"));
56+
}
57+
58+
[Test]
59+
public void NoTranslations_UsesFinalFallback()
60+
{
61+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(
62+
new List<AreaRuleTranslation>(), 2, "Compliance Item");
63+
64+
Assert.That(title, Is.EqualTo("Compliance Item"));
65+
}
66+
67+
[Test]
68+
public void NullTranslations_UsesFinalFallback()
69+
{
70+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(null, 2, "Compliance Item");
71+
72+
Assert.That(title, Is.EqualTo("Compliance Item"));
73+
}
74+
75+
[Test]
76+
public void AllEmptyTranslations_FallsBackToFinalFallback()
77+
{
78+
var translations = new List<AreaRuleTranslation>
79+
{
80+
T(1, ""),
81+
T(2, " ")
82+
};
83+
84+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, "Compliance Item");
85+
86+
Assert.That(title, Is.EqualTo("Compliance Item"));
87+
}
88+
89+
[Test]
90+
public void AllEmpty_AndNullFallback_ReturnsEmptyString()
91+
{
92+
var translations = new List<AreaRuleTranslation>
93+
{
94+
T(1, ""),
95+
T(2, null)
96+
};
97+
98+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, null);
99+
100+
Assert.That(title, Is.EqualTo(""));
101+
}
102+
103+
[Test]
104+
public void AllEmpty_AndWhitespaceFallback_ReturnsEmptyString()
105+
{
106+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(
107+
new List<AreaRuleTranslation>(), 2, " ");
108+
109+
Assert.That(title, Is.EqualTo(""));
110+
}
111+
112+
[Test]
113+
public void UserLanguageMissing_CrossLanguageFallback_WinsOverFinalFallback()
114+
{
115+
// Event 113 scenario: translation exists but keyed to a language the
116+
// reader didn't query — must degrade to the available title, not the
117+
// (often null) compliance fallback.
118+
var translations = new List<AreaRuleTranslation>
119+
{
120+
T(1, "Available Title")
121+
};
122+
123+
var title = BackendConfigurationCalendarService.ResolveTaskTitle(translations, 2, null);
124+
125+
Assert.That(title, Is.EqualTo("Available Title"));
126+
}
127+
}

eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/BackendConfiguration.Pn.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
1818
</PropertyGroup>
1919

20+
<ItemGroup>
21+
<InternalsVisibleTo Include="BackendConfiguration.Pn.Test" />
22+
</ItemGroup>
23+
2024
<ItemGroup>
2125
<None Remove="Resources\eForms\01. Miljøledelse_skabelon.xml" />
2226
<None Remove="Resources\eForms\01. Vandforbrug.xml" />

0 commit comments

Comments
 (0)