From eb23eb650b1db533fc2bc6057c5c302d4a97a117 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 18:57:46 +0200 Subject: [PATCH] feat: edit the reference week from the admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/horaires/WeekEditor.tsx | 275 +++++++++++++++++++++++++ app/admin/horaires/actions.ts | 94 +++++++++ app/admin/horaires/page.tsx | 35 ++++ app/admin/layout.tsx | 18 ++ lib/schedule/week.test.ts | 154 ++++++++++++++ lib/schedule/week.ts | 111 ++++++++++ tests/integration/hours-action.test.ts | 138 +++++++++++++ vitest.config.mts | 4 + 8 files changed, 829 insertions(+) create mode 100644 app/admin/horaires/WeekEditor.tsx create mode 100644 app/admin/horaires/actions.ts create mode 100644 app/admin/horaires/page.tsx create mode 100644 lib/schedule/week.test.ts create mode 100644 lib/schedule/week.ts create mode 100644 tests/integration/hours-action.test.ts diff --git a/app/admin/horaires/WeekEditor.tsx b/app/admin/horaires/WeekEditor.tsx new file mode 100644 index 0000000..7c0d3f0 --- /dev/null +++ b/app/admin/horaires/WeekEditor.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { useMemo, useState, useTransition } from 'react'; + +import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate'; +import { + changedDays, + DAY_NAMES_FR, + duplicateToOpenDays, + validateWeek, + type WeekDraft, +} from '@/lib/schedule/week'; + +import { saveWeek } from './actions'; + +type Props = { + initial: WeekDraft; + canEdit: boolean; +}; + +/** + * The reference week. + * + * Built for 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 of a change shown before it is saved rather than after. + */ +export default function WeekEditor({ initial, canEdit }: Props) { + const [week, setWeek] = useState(initial); + const [saved, setSaved] = useState(initial); + const [pending, startTransition] = useTransition(); + const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + const [serverErrors, setServerErrors] = useState>({}); + + const errors = useMemo(() => validateWeek(week), [week]); + const pendingChanges = useMemo(() => changedDays(saved, week), [saved, week]); + const blocked = Object.keys(errors).length > 0; + + function update(dayOfWeek: number, patch: Partial) { + setMessage(null); + setServerErrors({}); + setWeek((current) => + current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)), + ); + } + + function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) { + setWeek((current) => + current.map((day) => + day.dayOfWeek === dayOfWeek + ? { + ...day, + slots: day.slots.map((slot, position) => + position === index ? { ...slot, [field]: value } : slot, + ), + } + : day, + ), + ); + setMessage(null); + } + + function addSlot(dayOfWeek: number) { + update(dayOfWeek, { + slots: [ + ...(week.find((day) => day.dayOfWeek === dayOfWeek)?.slots ?? []), + { open: '14:00', close: '18:30' }, + ], + }); + } + + function removeSlot(dayOfWeek: number, index: number) { + const day = week.find((entry) => entry.dayOfWeek === dayOfWeek); + update(dayOfWeek, { slots: (day?.slots ?? []).filter((_, position) => position !== index) }); + } + + function submit() { + setMessage(null); + setServerErrors({}); + startTransition(async () => { + const result = await saveWeek(week); + if (result.ok) { + setSaved(week); + setMessage({ + kind: 'ok', + text: + result.changed.length === 0 + ? 'Aucun changement à enregistrer.' + : `Horaires enregistrés (${result.changed.length} jour${result.changed.length > 1 ? 's' : ''} modifié${result.changed.length > 1 ? 's' : ''}).`, + }); + } else { + setServerErrors(result.dayErrors ?? {}); + setMessage({ kind: 'error', text: result.error }); + } + }); + } + + return ( +
+ {!canEdit ? ( +

+ Ce compte est en lecture seule : les horaires sont consultables mais pas modifiables. +

+ ) : null} + +
+ {week.map((day) => { + const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])]; + const isChanged = pendingChanges.includes(day.dayOfWeek); + + return ( +
0 ? 'border-[var(--danger)]' : 'border-[var(--line)]' + }`} + > +
+

{DAY_NAMES_FR[day.dayOfWeek]}

+ + + + {isChanged ? ( + + modifié + + ) : null} +
+ + {day.isClosed ? ( +

Fermé toute la journée.

+ ) : ( +
+ {day.slots.map((slot, index) => ( +
+ + updateSlot(day.dayOfWeek, index, 'open', event.target.value) + } + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums" + /> + + – + + + updateSlot(day.dayOfWeek, index, 'close', event.target.value) + } + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums" + /> + {canEdit && day.slots.length > 1 ? ( + + ) : null} +
+ ))} + + {canEdit && day.slots.length < MAX_SLOTS_PER_DAY ? ( + + ) : null} + + {canEdit ? ( +
+ +
+ ) : null} +
+ )} + + {dayErrors.length > 0 ? ( +
    + {dayErrors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null} +
+ ); + })} +
+ + {canEdit ? ( +
+ + + {pendingChanges.length > 0 ? ( + + ) : null} + +

+ {blocked + ? 'Corrigez les erreurs ci-dessus.' + : pendingChanges.length === 0 + ? 'Aucune modification.' + : `${pendingChanges.length} jour${pendingChanges.length > 1 ? 's' : ''} sera modifié : ${pendingChanges + .map((dayOfWeek) => DAY_NAMES_FR[dayOfWeek]) + .join(', ')}.`} +

+
+ ) : null} + + {message ? ( +

+ {message.text} +

+ ) : null} +
+ ); +} diff --git a/app/admin/horaires/actions.ts b/app/admin/horaires/actions.ts new file mode 100644 index 0000000..22873ae --- /dev/null +++ b/app/admin/horaires/actions.ts @@ -0,0 +1,94 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { diffOf, recordAudit } from '@/lib/audit'; +import { requireAdmin } from '@/lib/auth/actions'; +import { prisma } from '@/lib/db'; +import type { Slot } from '@/lib/schedule/types'; +import { + changedDays, + DAY_NAMES_FR, + hasErrors, + normaliseWeek, + toDraft, + validateWeek, + type WeekDraft, +} from '@/lib/schedule/week'; + +export type SaveWeekResult = + | { ok: true; changed: number[] } + | { ok: false; error: string; dayErrors?: Record }; + +/** + * Persists the reference week. + * + * Validation runs again here even though the form already checked: the client + * is a convenience, not a guarantee, and this is the schedule the shop window + * shows. + */ +export async function saveWeek(draft: WeekDraft): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const week = normaliseWeek(draft); + const errors = validateWeek(week); + if (hasErrors(errors)) { + return { ok: false, error: 'Certains horaires sont invalides.', dayErrors: errors }; + } + + const existing = await prisma.weeklySchedule.findMany(); + const before = toDraft( + existing.map((day) => ({ + dayOfWeek: day.dayOfWeek, + isClosed: day.isClosed, + slots: (day.slots as Slot[] | null) ?? [], + })), + ); + + const changed = changedDays(before, week); + if (changed.length === 0) { + // Nothing moved: no write, no audit entry, no needless screen redraw. + return { ok: true, changed: [] }; + } + + await prisma.$transaction( + week.map((day) => + prisma.weeklySchedule.upsert({ + where: { dayOfWeek: day.dayOfWeek }, + update: { isClosed: day.isClosed, slots: day.slots }, + create: { dayOfWeek: day.dayOfWeek, isClosed: day.isClosed, slots: day.slots }, + }), + ), + ); + + await recordAudit({ + userEmail: gate.value.email, + action: 'weekly_schedule.update', + entity: 'WeeklySchedule', + diff: diffOf(summary(before), summary(week)), + }); + + revalidatePath('/admin'); + revalidatePath('/admin/horaires'); + + return { ok: true, changed }; +} + +/** + * One readable line per day for the audit trail. Storing the raw rows would + * make the diff accurate and unreadable; the point of the log is that a human + * can see what changed. + */ +function summary(week: WeekDraft): Record { + return Object.fromEntries( + week.map((day) => [ + DAY_NAMES_FR[day.dayOfWeek] ?? String(day.dayOfWeek), + day.isClosed || day.slots.length === 0 + ? 'Fermé' + : day.slots.map((slot) => `${slot.open}–${slot.close}`).join(' · '), + ]), + ); +} diff --git a/app/admin/horaires/page.tsx b/app/admin/horaires/page.tsx new file mode 100644 index 0000000..dae5c3e --- /dev/null +++ b/app/admin/horaires/page.tsx @@ -0,0 +1,35 @@ +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/db'; +import type { Slot } from '@/lib/schedule/types'; +import { toDraft } from '@/lib/schedule/week'; + +import WeekEditor from './WeekEditor'; + +export const metadata = { title: 'Horaires fixes — ITA ITO' }; + +export default async function HoursPage() { + const [session, rows] = await Promise.all([ + auth(), + prisma.weeklySchedule.findMany({ orderBy: { dayOfWeek: 'asc' } }), + ]); + + const initial = toDraft( + rows.map((row) => ({ + dayOfWeek: row.dayOfWeek, + isClosed: row.isClosed, + slots: (row.slots as Slot[] | null) ?? [], + })), + ); + + return ( +
+

Horaires fixes

+

+ La semaine de référence. Les fermetures ponctuelles, les vacances et les jours fériés se + règlent ailleurs et priment sur ces horaires. +

+ + +
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index e6a7d0e..6a90067 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -3,6 +3,12 @@ import Link from 'next/link'; import { auth, signOut } from '@/lib/auth'; +/** Extended as each page lands, so nothing in the bar leads nowhere. */ +const NAV = [ + { href: '/admin', label: 'Tableau de bord' }, + { href: '/admin/horaires', label: 'Horaires' }, +]; + /** * The admin shell. * @@ -29,6 +35,18 @@ export default async function AdminLayout({ children }: { children: React.ReactN /> + +
{!isAdmin ? ( diff --git a/lib/schedule/week.test.ts b/lib/schedule/week.test.ts new file mode 100644 index 0000000..6443d0c --- /dev/null +++ b/lib/schedule/week.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import { + changedDays, + duplicateToOpenDays, + hasErrors, + normaliseWeek, + toDraft, + validateWeek, + WEEK_ORDER, + type WeekDraft, +} from './week'; + +const FULL = [{ open: '10:00', close: '18:30' }]; +const SPLIT = [ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, +]; + +function week(overrides: Partial>> = {}): WeekDraft { + return WEEK_ORDER.map((dayOfWeek) => ({ + dayOfWeek, + isClosed: dayOfWeek === 0 || dayOfWeek === 1, + slots: dayOfWeek === 0 || dayOfWeek === 1 ? [] : FULL.map((slot) => ({ ...slot })), + ...overrides[dayOfWeek], + })); +} + +describe('toDraft', () => { + it('returns the week Monday first', () => { + expect(toDraft([]).map((day) => day.dayOfWeek)).toEqual([1, 2, 3, 4, 5, 6, 0]); + }); + + it('fills in a day the database does not have, as closed', () => { + const draft = toDraft([{ dayOfWeek: 2, isClosed: false, slots: FULL }]); + expect(draft.find((day) => day.dayOfWeek === 2)?.slots).toEqual(FULL); + expect(draft.find((day) => day.dayOfWeek === 5)).toMatchObject({ isClosed: true, slots: [] }); + }); + + it('puts slots in chronological order whatever the stored order', () => { + const draft = toDraft([{ dayOfWeek: 3, isClosed: false, slots: [SPLIT[1]!, SPLIT[0]!] }]); + expect(draft.find((day) => day.dayOfWeek === 3)?.slots).toEqual(SPLIT); + }); +}); + +describe('duplicateToOpenDays', () => { + it('copies the source day onto the other open days', () => { + const source = week({ 2: { slots: SPLIT } }); + const result = duplicateToOpenDays(source, 2); + + for (const dayOfWeek of [3, 4, 5, 6]) { + expect(result.find((day) => day.dayOfWeek === dayOfWeek)?.slots).toEqual(SPLIT); + } + }); + + it('leaves closed days closed', () => { + // "The days I open, I open like this" — not "open seven days a week". + const result = duplicateToOpenDays(week({ 2: { slots: SPLIT } }), 2); + expect(result.find((day) => day.dayOfWeek === 1)).toMatchObject({ + isClosed: true, + slots: [], + }); + }); + + it('does nothing when the source day is itself closed', () => { + const source = week(); + expect(duplicateToOpenDays(source, 1)).toEqual(source); + }); + + it('does nothing when the source day does not exist', () => { + const source = week(); + expect(duplicateToOpenDays(source, 42)).toEqual(source); + }); + + it('copies by value, so later edits do not leak between days', () => { + const result = duplicateToOpenDays(week({ 2: { slots: SPLIT } }), 2); + const tuesday = result.find((day) => day.dayOfWeek === 2)!; + const wednesday = result.find((day) => day.dayOfWeek === 3)!; + expect(wednesday.slots).not.toBe(tuesday.slots); + expect(wednesday.slots[0]).not.toBe(tuesday.slots[0]); + }); +}); + +describe('validateWeek', () => { + it('accepts a sound week', () => { + expect(hasErrors(validateWeek(week()))).toBe(false); + }); + + it('ignores the slots of a closed day', () => { + // Leftovers from before the day was closed must not block the save. + const draft = week({ 2: { isClosed: true, slots: [{ open: '18:00', close: '10:00' }] } }); + expect(hasErrors(validateWeek(draft))).toBe(false); + }); + + it('refuses an open day with no hours', () => { + const draft = week({ 2: { isClosed: false, slots: [] } }); + expect(validateWeek(draft)[2]).toContain('Un jour ouvert doit avoir au moins une plage horaire.'); + }); + + it('reports the problem against the day it belongs to', () => { + const draft = week({ 4: { slots: [{ open: '18:30', close: '10:00' }] } }); + const errors = validateWeek(draft); + expect(errors[4]?.length).toBeGreaterThan(0); + expect(errors[2]).toBeUndefined(); + }); + + it('catches overlapping slots', () => { + const draft = week({ + 3: { + slots: [ + { open: '10:00', close: '14:00' }, + { open: '13:00', close: '18:30' }, + ], + }, + }); + expect(errors(validateWeek(draft), 3)).toMatch(/chevauchent/); + }); +}); + +function errors(all: Record, day: number): string { + return (all[day] ?? []).join(' '); +} + +describe('normaliseWeek', () => { + it('strips the slots of a closed day', () => { + const draft = week({ 2: { isClosed: true, slots: FULL } }); + expect(normaliseWeek(draft).find((day) => day.dayOfWeek === 2)?.slots).toEqual([]); + }); + + it('orders the slots of an open day', () => { + const draft = week({ 3: { slots: [SPLIT[1]!, SPLIT[0]!] } }); + expect(normaliseWeek(draft).find((day) => day.dayOfWeek === 3)?.slots).toEqual(SPLIT); + }); +}); + +describe('changedDays', () => { + it('reports nothing when the week is untouched', () => { + expect(changedDays(week(), week())).toEqual([]); + }); + + it('ignores a reordering that changes nothing', () => { + const before = week({ 3: { slots: SPLIT } }); + const after = week({ 3: { slots: [SPLIT[1]!, SPLIT[0]!] } }); + expect(changedDays(before, after)).toEqual([]); + }); + + it('names a day whose hours moved', () => { + expect(changedDays(week(), week({ 4: { slots: SPLIT } }))).toEqual([4]); + }); + + it('names a day that opened or closed', () => { + expect(changedDays(week(), week({ 1: { isClosed: false, slots: FULL } }))).toEqual([1]); + }); +}); diff --git a/lib/schedule/week.ts b/lib/schedule/week.ts new file mode 100644 index 0000000..857b613 --- /dev/null +++ b/lib/schedule/week.ts @@ -0,0 +1,111 @@ +/** + * Editing helpers for the reference week. + * + * Pure, so the behaviour the shop owner actually touches — adding a lunch + * break, copying one day onto the others — is tested without a browser. + */ + +import { minutesOfTime } from './civil'; +import type { Slot, WeeklyScheduleEntry } from './types'; +import { validateSlots } from './validate'; + +/** Monday first: how the week is read, and how the screen draws it. */ +export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; + +export const DAY_NAMES_FR: Record = { + 0: 'Dimanche', + 1: 'Lundi', + 2: 'Mardi', + 3: 'Mercredi', + 4: 'Jeudi', + 5: 'Vendredi', + 6: 'Samedi', +}; + +export type WeekDraft = WeeklyScheduleEntry[]; + +/** A complete week, in display order, filling in any day the database lacks. */ +export function toDraft(entries: readonly WeeklyScheduleEntry[]): WeekDraft { + return WEEK_ORDER.map((dayOfWeek) => { + const found = entries.find((entry) => entry.dayOfWeek === dayOfWeek); + return { + dayOfWeek, + isClosed: found?.isClosed ?? true, + slots: sortSlots(found?.slots ?? []), + }; + }); +} + +/** + * Copies one day's hours onto every other day that is currently open. + * + * Closed days stay closed: someone duplicating Tuesday's hours means "the days + * I open, I open like this", not "open seven days a week". + */ +export function duplicateToOpenDays(week: WeekDraft, sourceDayOfWeek: number): WeekDraft { + const source = week.find((day) => day.dayOfWeek === sourceDayOfWeek); + if (!source || source.isClosed) { + return week; + } + return week.map((day) => + day.dayOfWeek === sourceDayOfWeek || day.isClosed + ? day + : { ...day, slots: source.slots.map((slot) => ({ ...slot })) }, + ); +} + +export type WeekErrors = Record; + +/** Validates every day, keyed by day so the message lands next to its row. */ +export function validateWeek(week: WeekDraft): WeekErrors { + const errors: WeekErrors = {}; + for (const day of week) { + if (day.isClosed) { + continue; + } + const dayErrors = validateSlots(day.slots); + if (day.slots.length === 0) { + dayErrors.push('Un jour ouvert doit avoir au moins une plage horaire.'); + } + if (dayErrors.length > 0) { + errors[day.dayOfWeek] = dayErrors; + } + } + return errors; +} + +export function hasErrors(errors: WeekErrors): boolean { + return Object.keys(errors).length > 0; +} + +/** + * Cleans a draft for storage: closed days lose their slots, open days keep + * them in chronological order. + */ +export function normaliseWeek(week: WeekDraft): WeekDraft { + return week.map((day) => ({ + dayOfWeek: day.dayOfWeek, + isClosed: day.isClosed, + slots: day.isClosed ? [] : sortSlots(day.slots), + })); +} + +/** Which days differ between two drafts, so the audit entry names only those. */ +export function changedDays(before: WeekDraft, after: WeekDraft): number[] { + return after + .filter((day) => { + const previous = before.find((entry) => entry.dayOfWeek === day.dayOfWeek); + if (!previous) { + return true; + } + return ( + previous.isClosed !== day.isClosed || + JSON.stringify(sortSlots(previous.slots)) !== JSON.stringify(sortSlots(day.slots)) + ); + }) + .map((day) => day.dayOfWeek); +} + +function sortSlots(slots: readonly Slot[]): Slot[] { + return [...slots].sort((a, b) => minutesOfTime(a.open) - minutesOfTime(b.open)); +} diff --git a/tests/integration/hours-action.test.ts b/tests/integration/hours-action.test.ts new file mode 100644 index 0000000..9d2faec --- /dev/null +++ b/tests/integration/hours-action.test.ts @@ -0,0 +1,138 @@ +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 { + 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 { + 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); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 665fff6..a3a9583 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,6 +13,10 @@ export default defineConfig({ // Runs before any module is imported, so the Prisma client is built // against the test database rather than the development one. setupFiles: ['tests/setup-database.ts'], + // The integration files share one database and each truncates it, so + // running files in parallel makes them race. The whole suite is a couple + // of seconds; giving every file its own database would buy nothing. + fileParallelism: false, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'],