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); }); });