Skip to content

Commit 5820715

Browse files
committed
add extra translation status tests
1 parent dd329bd commit 5820715

2 files changed

Lines changed: 489 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// SW-1278 — a publisher reports that the "Export text fields for translation"
2+
// tasklist item flips back to Incomplete after they import their completed
3+
// translation CSV. The unit-level tests in test/unit/dtos/tasklist-state-dto.test.ts
4+
// cover the status-calculation logic in isolation with `collectTranslations` mocked.
5+
// This file exercises the real round trip — datasetService.updateTranslations()
6+
// persists values to the DB, getTasklistState() reads them back via the real
7+
// collectTranslations() utility — which is where the bug would most plausibly hide.
8+
9+
import { dbManager } from '../../../src/db/database-manager';
10+
import { initPassport } from '../../../src/middleware/passport-auth';
11+
import { Dataset } from '../../../src/entities/dataset/dataset';
12+
import { Revision } from '../../../src/entities/dataset/revision';
13+
import { DimensionMetadata } from '../../../src/entities/dataset/dimension-metadata';
14+
import { RevisionMetadata } from '../../../src/entities/dataset/revision-metadata';
15+
import { EventLog } from '../../../src/entities/event-log';
16+
import { User } from '../../../src/entities/user/user';
17+
import { UserGroup } from '../../../src/entities/user/user-group';
18+
import { UserGroupRole } from '../../../src/entities/user/user-group-role';
19+
import { GroupRole } from '../../../src/enums/group-role';
20+
import { Locale } from '../../../src/enums/locale';
21+
import { TaskListStatus } from '../../../src/enums/task-list-status';
22+
import { DatasetService } from '../../../src/services/dataset';
23+
import { DatasetRepository, withMetadataForTranslation } from '../../../src/repositories/dataset';
24+
import { TranslationDTO } from '../../../src/dtos/translations-dto';
25+
import { collectTranslations } from '../../../src/utils/collect-translations';
26+
import { getFileService } from '../../../src/utils/get-file-service';
27+
import BlobStorage from '../../../src/services/blob-storage';
28+
import { ensureWorkerDataSources, resetDatabase } from '../../helpers/reset-database';
29+
import { getTestUser, getTestUserGroup } from '../../helpers/get-test-user';
30+
import { createFullDataset } from '../../helpers/test-helper';
31+
import { uuidV4 } from '../../../src/utils/uuid';
32+
33+
jest.mock('../../../src/services/blob-storage');
34+
BlobStorage.prototype.listFiles = jest
35+
.fn()
36+
.mockReturnValue([{ name: 'test-data-1.csv', path: 'test/test-data-1.csv', isDirectory: false }]);
37+
BlobStorage.prototype.saveBuffer = jest.fn();
38+
39+
const user: User = getTestUser('translation-test-user');
40+
let userGroup = getTestUserGroup('Translation Test Group');
41+
let datasetService: DatasetService;
42+
43+
interface SeededDataset {
44+
dataset: Dataset;
45+
revision: Revision;
46+
}
47+
48+
// Creates a dataset with both English and Welsh metadata seeded for the dimensions
49+
// and revision, and (optionally) a related link. updateTranslations() asserts that
50+
// each dimension already has a `cy` metadata row, so we have to put one there.
51+
async function seedDataset(opts: {
52+
title?: string;
53+
summary?: string;
54+
collection?: string;
55+
quality?: string;
56+
withRelatedLink?: boolean;
57+
dimensionColumnNames?: string[];
58+
}): Promise<SeededDataset> {
59+
const datasetId = uuidV4();
60+
const revisionId = uuidV4();
61+
const dataTableId = uuidV4();
62+
63+
await createFullDataset(datasetId, revisionId, dataTableId, user);
64+
65+
const revision = await Revision.findOne({
66+
where: { id: revisionId },
67+
relations: { metadata: true }
68+
});
69+
if (!revision) throw new Error('seed: revision not found');
70+
71+
// Fill every translatable metadata field with a realistic English value, so
72+
// exports won't contain null cells (which would get rejected by validation in
73+
// production anyway). The Welsh side starts blank — the round-trip simulates a
74+
// publisher who exports, fills in Welsh, and re-imports.
75+
const metaEn = revision.metadata.find((m) => m.language === Locale.EnglishGb)!;
76+
const metaCy = revision.metadata.find((m) => m.language === Locale.WelshGb)!;
77+
metaEn.title = opts.title ?? 'Healthy Child Wales Programme';
78+
metaCy.title = '';
79+
metaEn.summary = opts.summary ?? 'Annual percentage of incomplete contacts.';
80+
metaCy.summary = '';
81+
metaEn.collection = opts.collection ?? 'Data collection notes.';
82+
metaCy.collection = '';
83+
metaEn.quality = opts.quality ?? 'Quality statement.';
84+
metaCy.quality = '';
85+
await RevisionMetadata.getRepository().save([metaEn, metaCy]);
86+
87+
if (opts.withRelatedLink) {
88+
revision.relatedLinks = [
89+
{
90+
id: uuidV4(),
91+
url: 'https://example.gov.wales/related',
92+
labelEN: "Publisher's notes",
93+
labelCY: '',
94+
created_at: new Date().toISOString()
95+
}
96+
];
97+
await revision.save();
98+
}
99+
100+
// createFullDataset only attaches English metadata to dimensions. updateTranslations
101+
// requires a Welsh row to exist for each dimension — create blank Welsh rows here.
102+
const dataset = await DatasetRepository.getById(datasetId, withMetadataForTranslation);
103+
for (const dimension of dataset.dimensions) {
104+
const hasCy = dimension.metadata.some((m) => m.language.includes('cy'));
105+
if (!hasCy) {
106+
const cyMeta = DimensionMetadata.create({
107+
id: dimension.id,
108+
language: 'cy-GB',
109+
name: ''
110+
} as DimensionMetadata);
111+
await DimensionMetadata.getRepository().save(cyMeta);
112+
}
113+
}
114+
115+
// Reload to get the full state for the caller.
116+
const reloaded = await DatasetRepository.getById(datasetId, withMetadataForTranslation);
117+
return { dataset: reloaded, revision: reloaded.draftRevision! };
118+
}
119+
120+
// Simulates the controller flow without going through HTTP file streaming / AV scan:
121+
// 1. Save an `export` EventLog (as translationExport() does after streaming CSV).
122+
// 2. Call datasetService.updateTranslations() with the filled-in translations
123+
// (as applyImport() does once the user has confirmed the validated upload).
124+
// 3. Save an `import` EventLog (as applyImport() does after updateTranslations()).
125+
async function simulateExportThenImport(
126+
revisionId: string,
127+
exportedTranslations: TranslationDTO[],
128+
importedTranslations: TranslationDTO[],
129+
exportTime: Date
130+
): Promise<void> {
131+
await EventLog.getRepository().save({
132+
action: 'export',
133+
entity: 'translations',
134+
entityId: revisionId,
135+
data: exportedTranslations,
136+
userId: user.id,
137+
client: 'translation-integration-test',
138+
createdAt: exportTime
139+
});
140+
141+
// Drive applyImport's key call.
142+
const dataset = await DatasetRepository.getById(
143+
(await Revision.findOneByOrFail({ id: revisionId })).datasetId,
144+
withMetadataForTranslation
145+
);
146+
await datasetService.updateTranslations(dataset.id, importedTranslations);
147+
148+
await EventLog.getRepository().save({
149+
action: 'import',
150+
entity: 'translations',
151+
entityId: revisionId,
152+
data: importedTranslations,
153+
userId: user.id,
154+
client: 'translation-integration-test'
155+
});
156+
}
157+
158+
function fillWelsh(translations: TranslationDTO[]): TranslationDTO[] {
159+
return translations.map((t) => ({
160+
...t,
161+
cymraeg: t.cymraeg && t.cymraeg.length > 0 ? t.cymraeg : `${t.english ?? ''} (cy)`
162+
}));
163+
}
164+
165+
describe('Translation round trip (SW-1278)', () => {
166+
beforeAll(async () => {
167+
await ensureWorkerDataSources();
168+
await resetDatabase();
169+
await initPassport(dbManager.getAppDataSource());
170+
userGroup = await dbManager.getAppDataSource().getRepository(UserGroup).save(userGroup);
171+
user.groupRoles = [UserGroupRole.create({ group: userGroup, roles: [GroupRole.Editor] })];
172+
await user.save();
173+
const fileService = getFileService();
174+
datasetService = new DatasetService(Locale.EnglishGb, fileService);
175+
});
176+
177+
it('marks both export and import Completed after a clean round trip', async () => {
178+
const { dataset, revision } = await seedDataset({ title: 'My dataset' });
179+
const exported = collectTranslations(dataset);
180+
const imported = fillWelsh(exported);
181+
182+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
183+
184+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
185+
186+
expect(state.translation.import).toBe(TaskListStatus.Completed);
187+
expect(state.translation.export).toBe(TaskListStatus.Completed);
188+
});
189+
190+
it('marks both Completed after a round trip with whitespace-padded metadata values', async () => {
191+
// If `stringify` writes the padded value verbatim and the CSV parser's
192+
// `trim: true` strips it, the imported event payload will not match the
193+
// stored values. The unit suite proves this would surface as Incomplete —
194+
// here we check whether the real round trip suffers from it.
195+
const { dataset, revision } = await seedDataset({ title: ' Healthy Child Wales Programme ' });
196+
const exported = collectTranslations(dataset);
197+
const imported = fillWelsh(exported);
198+
199+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
200+
201+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
202+
203+
expect(state.translation.import).toBe(TaskListStatus.Completed);
204+
expect(state.translation.export).toBe(TaskListStatus.Completed);
205+
});
206+
207+
it('marks both Completed when metadata contains an apostrophe', async () => {
208+
const { dataset, revision } = await seedDataset({ title: "Children's wellbeing" });
209+
const exported = collectTranslations(dataset);
210+
const imported = fillWelsh(exported);
211+
212+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
213+
214+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
215+
216+
expect(state.translation.import).toBe(TaskListStatus.Completed);
217+
expect(state.translation.export).toBe(TaskListStatus.Completed);
218+
});
219+
220+
it('marks both Completed when revision has a related link', async () => {
221+
const { dataset, revision } = await seedDataset({ title: 'With related links', withRelatedLink: true });
222+
const exported = collectTranslations(dataset);
223+
const imported = fillWelsh(exported);
224+
225+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
226+
227+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
228+
229+
expect(state.translation.import).toBe(TaskListStatus.Completed);
230+
expect(state.translation.export).toBe(TaskListStatus.Completed);
231+
});
232+
233+
it('flips export Incomplete after the user edits metadata following a successful import', async () => {
234+
// This is the legitimate stale case. Pinned down to make sure a future fix
235+
// to SW-1278 does not over-correct and break it.
236+
const { dataset, revision } = await seedDataset({ title: 'Before edit' });
237+
const exported = collectTranslations(dataset);
238+
const imported = fillWelsh(exported);
239+
240+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
241+
242+
// User edits English title afterwards. RevisionMetadata's primary key column is
243+
// `revision_id` mapped to the property `id`.
244+
const metaEn = await RevisionMetadata.findOneByOrFail({
245+
id: revision.id,
246+
language: Locale.EnglishGb
247+
});
248+
metaEn.title = 'After edit';
249+
await metaEn.save();
250+
251+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
252+
253+
expect(state.translation.import).toBe(TaskListStatus.Incomplete);
254+
expect(state.translation.export).toBe(TaskListStatus.Incomplete);
255+
});
256+
257+
it('marks both Completed for an update revision where the `reason` metadata key is in scope', async () => {
258+
// The reported dataset is an update (Healthy Child Wales Programme has a published
259+
// first revision and was being updated). On update revisions, `reason` joins the
260+
// translatable keys, and updateTranslations() persists it via a dynamic assignment
261+
// that the TS cast at services/dataset.ts:236 doesn't list — useful to confirm
262+
// the runtime behaviour is correct.
263+
const { dataset, revision } = await seedDataset({ title: 'Update revision title' });
264+
265+
// Promote the seeded revision to look like an update: bump revisionIndex past 1.
266+
// collectTranslations() only checks revisionIndex to decide whether `reason` is in
267+
// scope; the previousRevisionId branch (the Unchanged short-circuit) only fires
268+
// when previousRevision is also loaded, which it isn't here.
269+
revision.revisionIndex = 2;
270+
await revision.save();
271+
272+
// Reload through the same accessor the service uses.
273+
const reloadedDataset = await DatasetRepository.getById(dataset.id, withMetadataForTranslation);
274+
const metaEn = reloadedDataset.draftRevision!.metadata.find((m) => m.language === Locale.EnglishGb)!;
275+
metaEn.reason = 'Annual refresh';
276+
await metaEn.save();
277+
const metaCy = reloadedDataset.draftRevision!.metadata.find((m) => m.language === Locale.WelshGb)!;
278+
metaCy.reason = '';
279+
await metaCy.save();
280+
281+
const refreshed = await DatasetRepository.getById(dataset.id, withMetadataForTranslation);
282+
const exported = collectTranslations(refreshed);
283+
const imported = fillWelsh(exported);
284+
285+
await simulateExportThenImport(revision.id, exported, imported, new Date(Date.now() - 60_000));
286+
287+
const state = await datasetService.getTasklistState(dataset.id, Locale.EnglishGb);
288+
289+
expect(state.translation.import).toBe(TaskListStatus.Completed);
290+
expect(state.translation.export).toBe(TaskListStatus.Completed);
291+
});
292+
});

0 commit comments

Comments
 (0)