feat: add the display messages page with live translation

French in, English out, with the character counter tied to the same
constant the renderer uses — so the warning and the space actually
available on the panel cannot drift apart.

Translation is a second call, not part of the save. A slow or broken
service must never cost the shop its message: the row is stored first
and marked pending, the translation follows, and a failure shows as a
badge with a retry rather than as a lost notice.

The status transitions are the subtle part and are pinned by tests.
Editing the French clears the English, including a translation someone
had corrected by hand — a translation of text that has changed is worse
than no translation. Editing only the dates or the priority leaves it
alone. A hand-written translation is never overwritten while its French
stands, and emptying it returns the row to pending.

"On the screen" is decided by the same selector the renderer uses, so
the badge cannot disagree with the panel about which message is live.

The preview is served by the device's own pipeline — payload, SVG,
threshold — so what the admin sees is the shop window down to the last
thresholded pixel. A preview drawn any other way would eventually
disagree with reality, quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 20:16:46 +02:00
co-authored by Claude Opus 5
parent f68ce1c3a5
commit f21268a9f6
7 changed files with 804 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { prisma } from '@/lib/db';
import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
import { hasDatabase, resetDatabase } from './helpers';
const caller = { email: 'test@ita-ito.test', role: 'admin' as const };
const gate = vi.hoisted(() => ({
result: { ok: true, value: { email: 'test@ita-ito.test', role: 'admin' as const } },
}));
const translation = vi.hoisted(() => ({
outcome: { ok: true, text: 'Closed today', model: 'loxi:haiku' } as
| { ok: true; text: string; model: string }
| { ok: false; error: string },
calls: 0,
}));
vi.mock('@/lib/auth/actions', () => ({ requireAdmin: async () => gate.result }));
vi.mock('next/cache', () => ({ revalidatePath: () => undefined }));
vi.mock('@/lib/translation/service', () => ({
translateToEnglish: async () => {
translation.calls += 1;
return translation.outcome;
},
}));
const { saveMessage, setManualTranslation, translateMessage, deleteMessage } = await import(
'@/app/admin/messages/actions'
);
const base = {
textFr: 'Fermé aujourd’hui',
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
};
describe.skipIf(!hasDatabase)('message actions', () => {
beforeEach(async () => {
gate.result = { ok: true, value: caller };
translation.outcome = { ok: true, text: 'Closed today', model: 'loxi:haiku' };
translation.calls = 0;
await resetDatabase();
await prisma.auditLog.deleteMany();
});
async function create(overrides = {}) {
const result = await saveMessage({ ...base, ...overrides });
if (!result.ok) {
throw new Error(result.error);
}
return result.value.id;
}
it('saves a message awaiting translation', async () => {
const id = await create();
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
// The save does not translate: a slow service must never cost the shop
// its message.
expect(row.translationStatus).toBe('PENDING');
expect(row.textEn).toBeNull();
expect(translation.calls).toBe(0);
});
it('translates on demand and records the result', async () => {
const id = await create();
expect(await translateMessage(id)).toMatchObject({ ok: true });
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
expect(row.textEn).toBe('Closed today');
expect(row.translationStatus).toBe('DONE');
});
it('marks a failed translation without losing the message', async () => {
const id = await create();
translation.outcome = { ok: false, error: 'quota dépassé' };
const result = await translateMessage(id);
expect(result.ok).toBe(false);
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
expect(row.translationStatus).toBe('ERROR');
expect(row.textFr).toBe(base.textFr);
});
it('keeps a hand-written translation and refuses to overwrite it', async () => {
const id = await create();
await setManualTranslation(id, ' Closed for the day ');
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
expect(row.textEn).toBe('Closed for the day');
expect(row.translationStatus).toBe('MANUAL');
const retry = await translateMessage(id);
expect(retry.ok).toBe(false);
expect(translation.calls).toBe(0);
expect((await prisma.message.findUniqueOrThrow({ where: { id } })).textEn).toBe(
'Closed for the day',
);
});
it('clears a hand-written translation once the French changes', async () => {
const id = await create();
await setManualTranslation(id, 'Closed for the day');
await saveMessage({ ...base, id, textFr: 'Ouverture retardée' });
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
// A translation of text that has changed is worse than no translation.
expect(row.textEn).toBeNull();
expect(row.translationStatus).toBe('PENDING');
});
it('keeps the translation when only the schedule changes', async () => {
const id = await create();
await translateMessage(id);
await saveMessage({ ...base, id, priority: 5, isActive: false });
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
expect(row.textEn).toBe('Closed today');
expect(row.translationStatus).toBe('DONE');
expect(row.priority).toBe(5);
expect(row.isActive).toBe(false);
});
it('empties a translation back to pending', async () => {
const id = await create();
await setManualTranslation(id, 'Closed for the day');
await setManualTranslation(id, ' ');
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
expect(row.textEn).toBeNull();
expect(row.translationStatus).toBe('PENDING');
});
it('refuses an empty message and one that would not fit', async () => {
expect((await saveMessage({ ...base, textFr: ' ' })).ok).toBe(false);
expect((await saveMessage({ ...base, textFr: 'a'.repeat(MESSAGE_MAX_CHARS + 1) })).ok).toBe(
false,
);
expect(await prisma.message.count()).toBe(0);
});
it('refuses a validity window that ends before it starts', async () => {
const result = await saveMessage({
...base,
startsAt: '2026-10-01T10:00',
endsAt: '2026-09-01T10:00',
});
expect(result.ok).toBe(false);
});
it('deletes a message and logs what it said', async () => {
const id = await create();
await prisma.auditLog.deleteMany();
expect(await deleteMessage(id)).toMatchObject({ ok: true });
expect(await prisma.message.count()).toBe(0);
const entry = await prisma.auditLog.findFirst();
expect(entry?.action).toBe('message.delete');
expect(entry?.diff).toMatchObject({ texte: { before: base.textFr, after: null } });
});
it('refuses a read-only account', async () => {
gate.result = { ok: false, error: 'Ce compte est en lecture seule.' } as never;
expect((await saveMessage(base)).ok).toBe(false);
expect(await prisma.message.count()).toBe(0);
});
});