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'],