The first administration page, built for the actual use case: a phone held in one hand behind the counter. One card per day, native time inputs so the platform keyboard does the work, and the consequences shown before the save rather than after — the footer names which days are about to change, and the button stays disabled until something actually has. "Duplicate onto the other open days" leaves closed days closed. Someone copying Tuesday's hours means "the days I open, I open like this", not "open seven days a week". Validation runs in the browser for the feedback and again in the action before the write: the client is a convenience, not a guarantee, and this is the schedule the shop window shows. A day being closed drops its leftover slots rather than failing on them. A save that changes nothing writes nothing — no rows, no audit entry, and so no needless panel redraw. Reordering slots does not count as a change. The audit diff stores one readable line per day in French, so the log can be read without cross-referencing the schema. The editing helpers are pure and tested, and the write path is tested against a real database including the read-only refusal. Test files now run sequentially: the integration files share one database and each truncates it, so parallel files raced. The suite takes six seconds; giving every file its own database would buy nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { prisma } from '@/lib/db';
|
||
import type { Slot } from '@/lib/schedule/types';
|
||
import { toDraft, type WeekDraft } from '@/lib/schedule/week';
|
||
|
||
import { hasDatabase, resetDatabase } from './helpers';
|
||
|
||
// The real module pulls in NextAuth, which drags half of Next into a test that
|
||
// only cares about what reaches the database.
|
||
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 } } }));
|
||
|
||
vi.mock('@/lib/auth/actions', () => ({
|
||
requireAdmin: async () => gate.result,
|
||
}));
|
||
vi.mock('next/cache', () => ({ revalidatePath: () => undefined }));
|
||
|
||
const { saveWeek } = await import('@/app/admin/horaires/actions');
|
||
|
||
const FULL: Slot[] = [{ open: '10:00', close: '18:30' }];
|
||
|
||
async function storedWeek(): Promise<WeekDraft> {
|
||
const rows = await prisma.weeklySchedule.findMany();
|
||
return toDraft(
|
||
rows.map((row) => ({
|
||
dayOfWeek: row.dayOfWeek,
|
||
isClosed: row.isClosed,
|
||
slots: (row.slots as Slot[] | null) ?? [],
|
||
})),
|
||
);
|
||
}
|
||
|
||
function withDay(week: WeekDraft, dayOfWeek: number, patch: Partial<WeekDraft[number]>): WeekDraft {
|
||
return week.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day));
|
||
}
|
||
|
||
describe.skipIf(!hasDatabase)('saveWeek', () => {
|
||
beforeEach(async () => {
|
||
gate.result = { ok: true, value: caller };
|
||
await resetDatabase();
|
||
await prisma.auditLog.deleteMany();
|
||
});
|
||
|
||
it('persists a changed day and names it in the result', async () => {
|
||
const week = withDay(await storedWeek(), 1, { isClosed: false, slots: FULL });
|
||
const result = await saveWeek(week);
|
||
|
||
expect(result).toEqual({ ok: true, changed: [1] });
|
||
const monday = await prisma.weeklySchedule.findUnique({ where: { dayOfWeek: 1 } });
|
||
expect(monday?.isClosed).toBe(false);
|
||
expect(monday?.slots).toEqual(FULL);
|
||
});
|
||
|
||
it('writes one readable audit entry', async () => {
|
||
await saveWeek(withDay(await storedWeek(), 1, { isClosed: false, slots: FULL }));
|
||
|
||
const entries = await prisma.auditLog.findMany();
|
||
expect(entries).toHaveLength(1);
|
||
expect(entries[0]).toMatchObject({
|
||
userEmail: caller.email,
|
||
action: 'weekly_schedule.update',
|
||
entity: 'WeeklySchedule',
|
||
});
|
||
// The diff names the day in French and shows both states, so the log can
|
||
// be read without cross-referencing the schema.
|
||
expect(entries[0]?.diff).toEqual({
|
||
Lundi: { before: 'Fermé', after: '10:00–18:30' },
|
||
});
|
||
});
|
||
|
||
it('writes nothing at all when nothing moved', async () => {
|
||
const result = await saveWeek(await storedWeek());
|
||
|
||
expect(result).toEqual({ ok: true, changed: [] });
|
||
// No audit entry either: a log full of empty saves is a log nobody reads.
|
||
expect(await prisma.auditLog.count()).toBe(0);
|
||
});
|
||
|
||
it('ignores a reordering that changes nothing', async () => {
|
||
const split: Slot[] = [
|
||
{ open: '10:00', close: '13:00' },
|
||
{ open: '14:00', close: '18:30' },
|
||
];
|
||
await saveWeek(withDay(await storedWeek(), 2, { isClosed: false, slots: split }));
|
||
await prisma.auditLog.deleteMany();
|
||
|
||
const reordered = withDay(await storedWeek(), 2, {
|
||
isClosed: false,
|
||
slots: [split[1]!, split[0]!],
|
||
});
|
||
expect(await saveWeek(reordered)).toEqual({ ok: true, changed: [] });
|
||
});
|
||
|
||
it('refuses overlapping slots and does not touch the database', async () => {
|
||
const before = await storedWeek();
|
||
const result = await saveWeek(
|
||
withDay(before, 2, {
|
||
isClosed: false,
|
||
slots: [
|
||
{ open: '10:00', close: '14:00' },
|
||
{ open: '13:00', close: '18:30' },
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.dayErrors?.[2]?.join(' ')).toMatch(/chevauchent/);
|
||
}
|
||
expect(await storedWeek()).toEqual(before);
|
||
});
|
||
|
||
it('refuses an open day with no hours', async () => {
|
||
const result = await saveWeek(
|
||
withDay(await storedWeek(), 2, { isClosed: false, slots: [] }),
|
||
);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('drops the leftover slots of a day being closed', async () => {
|
||
await saveWeek(withDay(await storedWeek(), 2, { isClosed: true, slots: FULL }));
|
||
const tuesday = await prisma.weeklySchedule.findUnique({ where: { dayOfWeek: 2 } });
|
||
expect(tuesday?.isClosed).toBe(true);
|
||
expect(tuesday?.slots).toEqual([]);
|
||
});
|
||
|
||
it('refuses a read-only account and leaves the week alone', async () => {
|
||
const before = await storedWeek();
|
||
gate.result = { ok: false, error: 'Ce compte est en lecture seule.' } as never;
|
||
|
||
const result = await saveWeek(withDay(before, 1, { isClosed: false, slots: FULL }));
|
||
|
||
expect(result.ok).toBe(false);
|
||
expect(await storedWeek()).toEqual(before);
|
||
expect(await prisma.auditLog.count()).toBe(0);
|
||
});
|
||
});
|