diff --git a/.claude/memory.md b/.claude/memory.md index aee0939c..9e097829 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -3,6 +3,16 @@ Append discoveries as you find them. Read this file at session start. Keep entries short: date, what, why. +## 2026-06-16 — Phase 46 KI-016 O: intake→completion e2e ("demo spine") (branch `feature/46-intake-completion-e2e`) + +- TEST/CI/DOCS only — zero product/runtime code changed; the spine was already correctly wired and now `__tests__/e2e/intake-to-completion.test.ts` proves it against a real DB. Gate green (lint/tc/prisma/2835 unit/build) + e2e 2 tests pass, idempotent across 3 reruns. +- **Real status-transition map (correct one — prompt had two wrong):** intake creates VR `UNTRIAGED` → inline `autoTriageService.triageEnquiry` promotes a *complete non-urgent* request to **PLANNING_POOL** (not just TRIAGED). Route proposal (`routeProposalService.generateProposals(targetDate?, originOverride?)` — it's a service method, NOT a bare `generateProposals()`) moves the VR to **CLUSTERED** (the prompt said "PROPOSED" — wrong; there is no PROPOSED planningStatus on the VR). Booking then needs the RouteRun at **APPROVED** first — there's a vet gate `routeRunRepository.update(id,{status:'APPROVED'})` (DRAFT→APPROVED) BETWEEN proposal and `bookingService.bookRouteRun`. Booking: Appointment PROPOSED + VR→BOOKED + RouteRun→BOOKED. Confirmation flips Appointment PROPOSED→CONFIRMED + writes ConfirmationDispatch. Completion: VisitOutcome + Appointment→COMPLETED + VR→COMPLETED + (followUpRequired) a new FOLLOW_UP VR in PLANNING_POOL. +- **To get a single fixture all the way to a PROPOSAL (the fiddly bit):** (a) customer must have exactly ONE yard → `yardMatcherService` returns high-confidence `sole-yard` so intake auto-fills `VisitRequest.yardId` (drives `needsMoreInfo:false` → PLANNING_POOL) regardless of message text; (b) the message must contain an explicit horse count (else `ASK_HORSE_COUNT` missing-field → stuck UNTRIAGED); (c) **horseCount must be ≥3** because `clustering.service` only keeps a single-stop cluster when `horseCount >= MIN_HORSES_FOR_SINGLE_STOP (3)` AND densityScore ≥ `minDensityScoreThreshold (20)` — a 1- or 2-horse lone yard yields NO proposal; (d) the yard needs lat/lng pre-set so the planner skips geocoding (offline-deterministic); (e) keep the e2e DB with **0 active VET staff** so `planPairing` collapses to the solo path (parallel path needs `activeVets.length>=2`). +- **Urgent path:** an urgent keyword → `urgencyLevel=URGENT`, `planningStatus=READY_FOR_REVIEW`, and `emergency.service.raiseUrgentAlert` sets `automationHold=true`; the proposal query excludes `automationHold:true` AND `urgencyLevel:'URGENT'`, so it's held out. With no `VET_ALERT_*` env + no active vet, the alert just warns + dead-letters — never throws (intake completes). +- **Real-DB test plumbing:** the prisma singleton has a soft-delete *read* extension (customer/yard/horse/enquiry) but it does NOT touch `deleteMany`/`create`, so cleanup hard-deletes fine. Pass `deletedAt: undefined` in cleanup `findMany` to also see tombstoned residue. Created rows (enquiry/VR/appt/routerun) get uuid ids — cleanup must walk relations from the known `e2e-` customer ids, not by id-prefix. Delete order: VisitOutcome (no cascade, FK line 724) before Appointment (which cascades dispatch/response/statusHistory/assignment). DEMO_MODE WhatsApp confirmation routes through the simulator → `success:true` so the CONFIRMED transition + dispatch row actually happen. +- **Vitest split:** default `vitest.config.ts` needed an explicit `exclude` (it had none → relied on vitest defaults) listing the vitest defaults + `__tests__/e2e/**`; new `vitest.e2e.config.ts` includes ONLY e2e, no react plugin, own setup that does NOT stub DATABASE_URL (fails fast if RUN_DB_E2E set but URL is the unit stub). Suite gated `describe.skipIf(!RUN_DB_E2E)`. CI: new additive `e2e` job (needs: check) with a `postgres:16` service container + `prisma migrate deploy` then `npm run test:e2e`; `check`/`docker`/`security` untouched (`docker` still `needs: check` only). +- New KI-031 (confirmation `shouldUseTemplate()` 24h-window is DEMO-only / `TODO(prod)`, Meta-gated, deferred). KI-016 O marked delivered. + Format: ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38f805b9..df4f368f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ on: - 'public/**' - 'i18n/**' - 'scripts/**' + - '__tests__/**' + - 'vitest.config.ts' + - 'vitest.e2e.config.ts' - 'auth.ts' - 'middleware.ts' - 'instrumentation.ts' @@ -55,6 +58,54 @@ jobs: - run: npm run test -- --passWithNoTests - run: npm run build + # Phase 46 — KI-016 O: real-database end-to-end "demo spine" test. + # Isolated, additive job — it does NOT touch the `check` gate above. + # Runs the intake → triage → proposal → booking → confirmation → + # completion pipeline against a fresh Postgres 16 service container with + # the real (non-stub) DATABASE_URL, after applying migrations. + e2e: + if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group' + runs-on: ubuntu-latest + needs: check + services: + postgres: + image: postgres:16 + # Throwaway credentials for an ephemeral CI service container (the + # same test:test convention the `check` job's stub DATABASE_URL uses) + # — not a secret; the container exists only for this job's lifetime. + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: equismile_e2e + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d equismile_e2e" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + # Real (connecting) service-container DB — unlike the `check` job's + # non-connecting stub. Throwaway test:test creds, ephemeral container. + DATABASE_URL: "postgresql://test:test@localhost:5432/equismile_e2e" + RUN_DB_E2E: "1" + DEMO_MODE: "true" + SKIP_ENV_VALIDATION: "true" + NEXT_PUBLIC_BUILD_SHA: ${{ github.sha }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx prisma generate + - name: Apply migrations to e2e database + run: npx prisma migrate deploy + - name: Run intake → completion e2e + run: npm run test:e2e + docker: if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group' runs-on: ubuntu-latest diff --git a/__tests__/e2e/intake-to-completion.test.ts b/__tests__/e2e/intake-to-completion.test.ts new file mode 100644 index 00000000..66a07a67 --- /dev/null +++ b/__tests__/e2e/intake-to-completion.test.ts @@ -0,0 +1,541 @@ +/** + * Phase 46 — KI-016 O: full intake → completion end-to-end ("demo spine"). + * + * This is a REAL-DATABASE test. Unlike the 2,800+ unit tests it does NOT + * mock `@/lib/prisma` or `@/lib/env`: it drives the actual domain services + * against a freshly-migrated Postgres schema (the same shape CI applies), + * proving every status hand-off across the whole pipeline really connects: + * + * intake → auto-triage → route proposal → approval → booking → + * confirmation → completion → follow-up regeneration + * + * It is deliberately gated behind `RUN_DB_E2E` so the ordinary mocked unit + * suite (`npm run test`) never imports it (it is also excluded by path in + * `vitest.config.ts`). Run it with the dedicated config: + * + * DATABASE_URL=postgresql://… RUN_DB_E2E=1 DEMO_MODE=true npm run test:e2e + * + * Fixtures use `e2e-` prefixed ids and are torn down in `afterAll`, so the + * suite is idempotent and re-runnable. Enquiries / visit requests / + * appointments / route runs are created by the services themselves (uuid + * ids); cleanup cascades from the fixture customers down through every + * child row. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { prisma } from '@/lib/prisma'; +import { + processWhatsAppPayload, + type WhatsAppPayload, +} from '@/lib/services/whatsapp-intake.service'; +import { routeProposalService } from '@/lib/services/route-proposal.service'; +import { routeRunRepository } from '@/lib/repositories/route-run.repository'; +import { bookingService } from '@/lib/services/booking.service'; +import { confirmationService } from '@/lib/services/confirmation.service'; +import { visitOutcomeService } from '@/lib/services/visit-outcome.service'; + +// -------------------------------------------------------------------------- +// Gate: only run when an operator explicitly opts in AND a real DB is wired. +// Never runs under the normal mocked unit suite. +// -------------------------------------------------------------------------- +const RUN_DB_E2E = process.env.RUN_DB_E2E === '1' || process.env.RUN_DB_E2E === 'true'; + +// -------------------------------------------------------------------------- +// Deterministic fixtures. ROUTINE customer has exactly ONE yard so the +// yard matcher resolves a high-confidence "sole-yard" hit (no postcode +// needed in the message) — this drives the request straight to the +// planning pool. 3 horses clears the single-stop clustering threshold +// (MIN_HORSES_FOR_SINGLE_STOP) so a proposal is actually generated. +// -------------------------------------------------------------------------- +const ROUTINE_PREFIX = 'e2e-routine'; +const URGENT_PREFIX = 'e2e-urgent'; + +const ROUTINE = { + customerId: `${ROUTINE_PREFIX}-cust`, + yardId: `${ROUTINE_PREFIX}-yard`, + horseId: `${ROUTINE_PREFIX}-horse`, + phone: '+41790000146', // unique; resolveInboundCustomer matches on this + waMessageId: `wamid.${ROUTINE_PREFIX}-1`, +}; + +const URGENT = { + customerId: `${URGENT_PREFIX}-cust`, + yardId: `${URGENT_PREFIX}-yard`, + horseId: `${URGENT_PREFIX}-horse`, + phone: '+41790000931', + waMessageId: `wamid.${URGENT_PREFIX}-1`, +}; + +/** All fixture customer ids — the root of every cleanup traversal. */ +const ALL_CUSTOMER_IDS = [ROUTINE.customerId, URGENT.customerId]; + +// Geneva-area coordinates so the yard is pre-geocoded (the planner skips +// geocoding when lat/lng are present, keeping the test offline-deterministic). +const GENEVA = { lat: 46.2044, lng: 6.1432 }; + +function buildWhatsAppPayload(opts: { + phone: string; + messageId: string; + text: string; + name: string; +}): WhatsAppPayload { + return { + object: 'whatsapp_business_account', + entry: [ + { + id: 'e2e-entry', + changes: [ + { + field: 'messages', + value: { + messaging_product: 'whatsapp', + metadata: { + display_phone_number: '+41000000000', + phone_number_id: 'e2e-phone-id', + }, + contacts: [ + { + profile: { name: opts.name }, + wa_id: opts.phone, + }, + ], + messages: [ + { + id: opts.messageId, + from: opts.phone, + timestamp: String(Math.floor(Date.now() / 1000)), + type: 'text', + text: { body: opts.text }, + }, + ], + }, + }, + ], + }, + ], + }; +} + +/** + * Hard-delete every row reachable from the fixture customers, child-first + * to satisfy FK constraints. Created rows (enquiries / visit requests / + * appointments / route runs) carry uuid ids, so cleanup discovers them by + * walking relations from the known customer ids rather than by id prefix. + */ +async function cleanup(): Promise { + // `deletedAt: undefined` opts out of the soft-delete read filter so we + // also see any tombstoned fixture rows from a previous failed run. + const visitRequests = await prisma.visitRequest.findMany({ + where: { customerId: { in: ALL_CUSTOMER_IDS } }, + select: { id: true, enquiryId: true }, + }); + const visitRequestIds = visitRequests.map((v) => v.id); + const enquiryIdsFromVrs = visitRequests + .map((v) => v.enquiryId) + .filter((id): id is string => !!id); + + const enquiries = await prisma.enquiry.findMany({ + where: { customerId: { in: ALL_CUSTOMER_IDS }, deletedAt: undefined }, + select: { id: true }, + }); + const enquiryIds = [...new Set([...enquiries.map((e) => e.id), ...enquiryIdsFromVrs])]; + + const appointments = visitRequestIds.length + ? await prisma.appointment.findMany({ + where: { visitRequestId: { in: visitRequestIds } }, + select: { id: true, routeRunId: true }, + }) + : []; + const appointmentIds = appointments.map((a) => a.id); + + const stops = visitRequestIds.length + ? await prisma.routeRunStop.findMany({ + where: { visitRequestId: { in: visitRequestIds } }, + select: { routeRunId: true }, + }) + : []; + const routeRunIds = [ + ...new Set([ + ...appointments.map((a) => a.routeRunId).filter((id): id is string => !!id), + ...stops.map((s) => s.routeRunId), + ]), + ]; + + // 1. Appointment children with no cascade (VisitOutcome / Vaccination) — + // then the appointments themselves (cascades dispatch / response / + // history / assignment). + if (appointmentIds.length) { + await prisma.visitOutcome.deleteMany({ where: { appointmentId: { in: appointmentIds } } }); + await prisma.vaccination.deleteMany({ where: { appointmentId: { in: appointmentIds } } }); + await prisma.appointment.deleteMany({ where: { id: { in: appointmentIds } } }); + } + + // 2. Route runs + their stops. + if (routeRunIds.length) { + await prisma.routeRunStop.deleteMany({ where: { routeRunId: { in: routeRunIds } } }); + await prisma.routeRun.deleteMany({ where: { id: { in: routeRunIds } } }); + } + + // 3. Visit-request children (FK), polymorphic Tasks (no FK), then the + // visit requests. Includes any follow-up VR (it shares the customerId). + if (visitRequestIds.length) { + await prisma.triageTask.deleteMany({ where: { visitRequestId: { in: visitRequestIds } } }); + await prisma.triageAuditLog.deleteMany({ where: { visitRequestId: { in: visitRequestIds } } }); + await prisma.task.deleteMany({ + where: { sourceType: 'VisitRequest', sourceId: { in: visitRequestIds } }, + }); + await prisma.idempotencyKey.deleteMany({ + where: { key: { in: visitRequestIds.map((id) => `urgent-alert:${id}`) } }, + }); + await prisma.visitRequest.deleteMany({ where: { id: { in: visitRequestIds } } }); + } + // Tasks spawned by completion deep-link to the appointment, not the VR. + if (appointmentIds.length) { + await prisma.task.deleteMany({ + where: { sourceType: 'Appointment', sourceId: { in: appointmentIds } }, + }); + } + + // 4. Enquiry children, then enquiries. + if (enquiryIds.length) { + await prisma.enquiryMessage.deleteMany({ where: { enquiryId: { in: enquiryIds } } }); + await prisma.enquiry.deleteMany({ where: { id: { in: enquiryIds } } }); + } + + // 5. Horses, yards, customers (deepest fixtures last). + await prisma.horse.deleteMany({ where: { customerId: { in: ALL_CUSTOMER_IDS } } }); + await prisma.yard.deleteMany({ where: { customerId: { in: ALL_CUSTOMER_IDS } } }); + await prisma.customer.deleteMany({ where: { id: { in: ALL_CUSTOMER_IDS } } }); +} + +describe.skipIf(!RUN_DB_E2E)('E2E: intake → completion demo spine (real DB)', () => { + beforeAll(async () => { + // Clean any residue from a prior interrupted run, then seed fresh. + await cleanup(); + + // --- ROUTINE customer: one yard (sole-yard high-confidence match) --- + await prisma.customer.create({ + data: { + id: ROUTINE.customerId, + fullName: 'E2E Routine Customer', + mobilePhone: ROUTINE.phone, + preferredChannel: 'WHATSAPP', + preferredLanguage: 'en', + }, + }); + await prisma.yard.create({ + data: { + id: ROUTINE.yardId, + customerId: ROUTINE.customerId, + yardName: 'E2E Routine Yard', + addressLine1: '1 Route de Test', + town: 'Geneva', + postcode: '1201', + latitude: GENEVA.lat, + longitude: GENEVA.lng, + geocodedAt: new Date(), + }, + }); + await prisma.horse.create({ + data: { + id: ROUTINE.horseId, + customerId: ROUTINE.customerId, + primaryYardId: ROUTINE.yardId, + horseName: 'E2ERoutineHorse', + }, + }); + + // --- URGENT customer: one yard too (kept out of proposals by the hold) --- + await prisma.customer.create({ + data: { + id: URGENT.customerId, + fullName: 'E2E Urgent Customer', + mobilePhone: URGENT.phone, + preferredChannel: 'WHATSAPP', + preferredLanguage: 'en', + }, + }); + await prisma.yard.create({ + data: { + id: URGENT.yardId, + customerId: URGENT.customerId, + yardName: 'E2E Urgent Yard', + addressLine1: '2 Route de Test', + town: 'Geneva', + postcode: '1201', + latitude: GENEVA.lat, + longitude: GENEVA.lng, + geocodedAt: new Date(), + }, + }); + await prisma.horse.create({ + data: { + id: URGENT.horseId, + customerId: URGENT.customerId, + primaryYardId: URGENT.yardId, + horseName: 'E2EUrgentHorse', + }, + }); + }); + + afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); + }); + + it('walks the whole spine asserting every status hand-off + follow-up regen', async () => { + // ==================================================================== + // STAGE 1 — INTAKE. A crafted inbound WhatsApp message from a known + // phone creates Enquiry + EnquiryMessage + VisitRequest and runs + // auto-triage inline. + // ==================================================================== + const payload = buildWhatsAppPayload({ + phone: ROUTINE.phone, + messageId: ROUTINE.waMessageId, + // routine keyword + explicit horse count → no missing fields once the + // sole-yard match supplies the location. + text: 'Hello, please could we book a routine dental check for 3 horses when you are next in the area?', + name: 'E2E Routine Customer', + }); + + const intake = await processWhatsAppPayload(payload); + expect(intake.processed).toHaveLength(1); + const processed = intake.processed[0]; + expect(processed.customerId).toBe(ROUTINE.customerId); // matched existing customer, not new + expect(processed.isNewCustomer).toBe(false); + expect(processed.isUrgent).toBe(false); + expect(processed.visitRequestId).toBeTruthy(); + + const visitRequestId = processed.visitRequestId!; + const enquiryId = processed.enquiryId; + + // Enquiry + the inbound message were persisted. + const enquiry = await prisma.enquiry.findUnique({ where: { id: enquiryId } }); + expect(enquiry).not.toBeNull(); + expect(enquiry!.channel).toBe('WHATSAPP'); + expect(enquiry!.externalMessageId).toBe(ROUTINE.waMessageId); + + const messages = await prisma.enquiryMessage.findMany({ where: { enquiryId } }); + expect(messages).toHaveLength(1); + expect(messages[0].direction).toBe('INBOUND'); + expect(messages[0].channel).toBe('WHATSAPP'); + + // ==================================================================== + // STAGE 2 — TRIAGE (ran inline inside intake). A complete, non-urgent + // request is auto-promoted to the planning pool, the yard is matched + // with high confidence, and the enquiry is marked TRIAGED. + // ==================================================================== + const triagedVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(triagedVr).not.toBeNull(); + expect(triagedVr!.planningStatus).toBe('PLANNING_POOL'); + expect(triagedVr!.urgencyLevel).toBe('ROUTINE'); + expect(triagedVr!.needsMoreInfo).toBe(false); + expect(triagedVr!.automationHold).toBe(false); + expect(triagedVr!.yardId).toBe(ROUTINE.yardId); // sole-yard high-confidence match + expect(triagedVr!.horseCount).toBe(3); + + const triagedEnquiry = await prisma.enquiry.findUnique({ where: { id: enquiryId } }); + expect(triagedEnquiry!.triageStatus).toBe('TRIAGED'); + + // ==================================================================== + // STAGE 3 — ROUTE PROPOSAL. The planner pulls the pooled request, + // clusters it (3 horses clears the single-stop threshold), and writes + // a DRAFT RouteRun + RouteRunStop. The visit request moves to CLUSTERED. + // (DEMO maps simulator handles geocoding/optimisation; here the yard is + // pre-geocoded so no network call is made.) + // ==================================================================== + const proposals = await routeProposalService.generateProposals(); + // Find the proposal that contains our visit request (the pool may hold + // other rows in a shared DB, though in the e2e DB it is just ours). + const ourProposal = proposals.find((p) => + p.stops.some((s) => s.visitRequestId === visitRequestId), + ); + expect(ourProposal, 'a DRAFT route proposal containing our visit request').toBeTruthy(); + expect(ourProposal!.status).toBe('DRAFT'); + expect(ourProposal!.totalHorses).toBe(3); + + const routeRunId = ourProposal!.routeRunId; + const draftRun = await prisma.routeRun.findUnique({ + where: { id: routeRunId }, + include: { stops: true }, + }); + expect(draftRun).not.toBeNull(); + expect(draftRun!.status).toBe('DRAFT'); + expect(draftRun!.stops.length).toBeGreaterThanOrEqual(1); + const ourStop = draftRun!.stops.find((s) => s.visitRequestId === visitRequestId); + expect(ourStop).toBeTruthy(); + expect(ourStop!.yardId).toBe(ROUTINE.yardId); + + const clusteredVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(clusteredVr!.planningStatus).toBe('CLUSTERED'); + + // ==================================================================== + // GATE 1 (vet) — the vet approves the DRAFT run. This is the human + // decision the planner cannot make autonomously. DRAFT → APPROVED. + // ==================================================================== + await routeRunRepository.update(routeRunId, { status: 'APPROVED' }); + const approvedRun = await prisma.routeRun.findUnique({ where: { id: routeRunId } }); + expect(approvedRun!.status).toBe('APPROVED'); + + // ==================================================================== + // STAGE 4 — BOOKING. The approved run becomes appointments. Each + // Appointment is created PROPOSED; the visit request flips to BOOKED; + // the run flips to BOOKED. + // ==================================================================== + const booking = await bookingService.bookRouteRun(routeRunId); + expect(booking.appointmentCount).toBeGreaterThanOrEqual(1); + expect(booking.appointmentIds.length).toBe(booking.appointmentCount); + + const bookedRun = await prisma.routeRun.findUnique({ where: { id: routeRunId } }); + expect(bookedRun!.status).toBe('BOOKED'); + + const bookedVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(bookedVr!.planningStatus).toBe('BOOKED'); + + // Resolve the appointment for OUR visit request. + const appointment = await prisma.appointment.findFirst({ + where: { visitRequestId, routeRunId }, + }); + expect(appointment).not.toBeNull(); + expect(appointment!.status).toBe('PROPOSED'); + const appointmentId = appointment!.id; + + // The initial PROPOSED status was recorded in history. + const proposedHistory = await prisma.appointmentStatusHistory.findFirst({ + where: { appointmentId, toStatus: 'PROPOSED' }, + }); + expect(proposedHistory).not.toBeNull(); + + // ==================================================================== + // GATE 2 (vet) — sending confirmations is a customer-facing commitment + // the vet triggers. STAGE 5 — CONFIRMATION. In DEMO_MODE the WhatsApp + // send goes through the simulator (success:true), so the appointment is + // atomically flipped PROPOSED → CONFIRMED and a ConfirmationDispatch row + // is written (success or failure — every attempt is logged). + // ==================================================================== + const confirmations = await confirmationService.sendConfirmationsForRouteRun(routeRunId, { + actor: 'e2e-test', + }); + const ourConfirmation = confirmations.find((c) => c.appointmentId === appointmentId); + expect(ourConfirmation, 'a confirmation result for our appointment').toBeTruthy(); + expect(ourConfirmation!.channel).toBe('WHATSAPP'); + expect(ourConfirmation!.sent).toBe(true); // demo simulator returns success + + const confirmedAppt = await prisma.appointment.findUnique({ where: { id: appointmentId } }); + expect(confirmedAppt!.status).toBe('CONFIRMED'); + expect(confirmedAppt!.confirmationSentAt).not.toBeNull(); + + const dispatch = await prisma.confirmationDispatch.findFirst({ where: { appointmentId } }); + expect(dispatch, 'a ConfirmationDispatch audit row').not.toBeNull(); + expect(dispatch!.channel).toBe('WHATSAPP'); + expect(dispatch!.success).toBe(true); + + const confirmedHistory = await prisma.appointmentStatusHistory.findFirst({ + where: { appointmentId, fromStatus: 'PROPOSED', toStatus: 'CONFIRMED' }, + }); + expect(confirmedHistory).not.toBeNull(); + + // ==================================================================== + // GATE 3 (vet) — completion is a clinical outcome the vet records. + // STAGE 6 — COMPLETION with followUpRequired:true. Creates VisitOutcome, + // flips Appointment → COMPLETED and the visit request → COMPLETED, and + // regenerates a NEW follow-up visit request back in PLANNING_POOL. + // ==================================================================== + const followUpDueDate = '2026-12-01'; + const completion = await visitOutcomeService.completeAppointment( + appointmentId, + { + notes: 'E2E spine — routine dental done; recommend follow-up.', + followUpRequired: true, + followUpDueDate, + }, + { actor: 'e2e-test' }, + ); + expect(completion.appointmentId).toBe(appointmentId); + expect(completion.visitOutcomeId).toBeTruthy(); + expect(completion.followUpVisitRequestId, 'follow-up visit request id').toBeTruthy(); + + const completedAppt = await prisma.appointment.findUnique({ where: { id: appointmentId } }); + expect(completedAppt!.status).toBe('COMPLETED'); + + const completedVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(completedVr!.planningStatus).toBe('COMPLETED'); + + const outcome = await prisma.visitOutcome.findUnique({ + where: { id: completion.visitOutcomeId }, + }); + expect(outcome).not.toBeNull(); + expect(outcome!.appointmentId).toBe(appointmentId); + expect(outcome!.followUpRequired).toBe(true); + + // The follow-up regeneration assertion — the prize of the whole spine. + const followUp = await prisma.visitRequest.findUnique({ + where: { id: completion.followUpVisitRequestId! }, + }); + expect(followUp, 'a regenerated follow-up visit request').not.toBeNull(); + expect(followUp!.planningStatus).toBe('PLANNING_POOL'); + expect(followUp!.requestType).toBe('FOLLOW_UP'); + expect(followUp!.customerId).toBe(ROUTINE.customerId); + expect(followUp!.yardId).toBe(ROUTINE.yardId); + expect(followUp!.earliestBookDate?.toISOString().slice(0, 10)).toBe(followUpDueDate); + + // A status-history row was recorded for the CONFIRMED → COMPLETED transition. + const completedHistory = await prisma.appointmentStatusHistory.findFirst({ + where: { appointmentId, toStatus: 'COMPLETED' }, + }); + expect(completedHistory).not.toBeNull(); + expect(completedHistory!.fromStatus).toBe('CONFIRMED'); + }); + + it('holds an URGENT enquiry out of automated proposals (automation hold path)', async () => { + // ==================================================================== + // INTAKE + TRIAGE — an urgent message triages as URGENT, lands in + // READY_FOR_REVIEW, and raises the automation hold so it is excluded + // from every automated route proposal until a vet clears it. + // ==================================================================== + const payload = buildWhatsAppPayload({ + phone: URGENT.phone, + messageId: URGENT.waMessageId, + text: 'URGENT please — my horse is in pain and bleeding from the mouth and will not eat.', + name: 'E2E Urgent Customer', + }); + + const intake = await processWhatsAppPayload(payload); + expect(intake.processed).toHaveLength(1); + const processed = intake.processed[0]; + expect(processed.customerId).toBe(URGENT.customerId); + expect(processed.isUrgent).toBe(true); + expect(processed.visitRequestId).toBeTruthy(); + + const visitRequestId = processed.visitRequestId!; + const urgentVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(urgentVr).not.toBeNull(); + expect(urgentVr!.urgencyLevel).toBe('URGENT'); + expect(urgentVr!.planningStatus).toBe('READY_FOR_REVIEW'); + // The durable safety gate: held out of automated scheduling. + expect(urgentVr!.automationHold).toBe(true); + expect(urgentVr!.automationHoldReason).toBeTruthy(); + expect(urgentVr!.automationHeldAt).not.toBeNull(); + + // An URGENT_REVIEW triage task was raised for the vet. + const urgentTask = await prisma.triageTask.findFirst({ + where: { visitRequestId, taskType: 'URGENT_REVIEW' }, + }); + expect(urgentTask, 'an URGENT_REVIEW triage task').not.toBeNull(); + + // ==================================================================== + // PROPOSAL EXCLUSION — generate proposals; the held urgent request must + // NOT appear in any of them (the stop-automation gate). + // ==================================================================== + const proposals = await routeProposalService.generateProposals(); + const appearsInAnyProposal = proposals.some((p) => + p.stops.some((s) => s.visitRequestId === visitRequestId), + ); + expect(appearsInAnyProposal, 'urgent request must be excluded from proposals').toBe(false); + + // And it stays held / READY_FOR_REVIEW (the proposal pass never touched it). + const afterVr = await prisma.visitRequest.findUnique({ where: { id: visitRequestId } }); + expect(afterVr!.automationHold).toBe(true); + expect(afterVr!.planningStatus).toBe('READY_FOR_REVIEW'); + }); +}); diff --git a/__tests__/e2e/setup.ts b/__tests__/e2e/setup.ts new file mode 100644 index 00000000..6e0a04b4 --- /dev/null +++ b/__tests__/e2e/setup.ts @@ -0,0 +1,30 @@ +/** + * E2E test setup (Phase 46 — KI-016 O). + * + * Unlike the unit `__tests__/setup.ts`, this file deliberately does NOT + * stub `DATABASE_URL`. The e2e suite talks to a real, freshly-migrated + * Postgres, so a stub would silently point the singleton Prisma client at + * a non-existent host and the whole pipeline would fail with opaque + * connection errors. + * + * Guard: when `RUN_DB_E2E` is set (the suite WILL actually run) we require + * a real `DATABASE_URL` and fail fast if it is missing or still the unit + * stub. When `RUN_DB_E2E` is unset the suite bodies are skipped + * (`describe.skipIf`), so we leave the environment untouched. + */ + +const STUB_DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; + +const runDbE2e = process.env.RUN_DB_E2E === '1' || process.env.RUN_DB_E2E === 'true'; + +if (runDbE2e) { + const url = process.env.DATABASE_URL; + if (!url || url === STUB_DATABASE_URL) { + throw new Error( + 'RUN_DB_E2E is set but DATABASE_URL is missing or is the unit-test stub. ' + + 'The e2e suite needs a real, migrated Postgres. Set DATABASE_URL to it, e.g.:\n' + + ' DATABASE_URL=postgresql://equismile:equismile_dev_pw@localhost:5432/equismile_e2e ' + + 'RUN_DB_E2E=1 DEMO_MODE=true npm run test:e2e', + ); + } +} diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index ff45282d..906cb826 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -1,5 +1,12 @@ # EquiSmile Known Issues +## Phase 46 — KI-016 O: intake→completion end-to-end ("demo spine") (2026-06-16) + +| ID | Severity | Description | Resolution | +|----|----------|-------------|------------| +| KI-016 O | Test/CI | Full-flow intake → confirmation (→ completion) end-to-end was the last deferred slice of KI-016. The pipeline was already fully wired; what was missing was a single test proving every stage really connects against a real DB, plus CI coverage. | **Delivered.** New real-database test `__tests__/e2e/intake-to-completion.test.ts` drives the whole spine through the actual domain services (no `@/lib/prisma` / `@/lib/env` mocks): intake (`processWhatsAppPayload`) → inline auto-triage → route proposal (`routeProposalService.generateProposals`) → vet approval (DRAFT→APPROVED) → booking (`bookingService.bookRouteRun`) → confirmation (`confirmationService.sendConfirmationsForRouteRun`) → completion (`visitOutcomeService.completeAppointment`) → follow-up regeneration, asserting the exact status transition at every hand-off. A second test asserts the URGENT → `automationHold` path is held out of proposals. Self-contained `e2e-`-prefixed fixtures, child-first cleanup (idempotent / re-runnable). Gated by `describe.skipIf(!RUN_DB_E2E)` + excluded from the default `vitest.config.ts`, so `npm run test` (2,835 unit tests) never touches it; run via `npm run test:e2e` with its own `vitest.e2e.config.ts`. CI runs it in an isolated, additive `e2e` job (Postgres 16 service container + `prisma migrate deploy`) in `.github/workflows/ci.yml`. UI walkthrough: `docs/PHASE_46_KI_016_O_DEMO_WALKTHROUGH.md`. **No product/runtime code changed — the spine was already correct, and is now proven.** | +| KI-031 | Info | The confirmation service's `shouldUseTemplate()` (`lib/services/confirmation.service.ts`) is **DEMO-only** for the 24-hour customer-service-window decision: in demo mode it always uses the approved-template path, but the production branch is a `TODO(prod)` — it does not yet check `EnquiryMessage` for the customer's most-recent inbound message and fall back to a template when older than 24h, so production currently always falls through to free text. | **Deferred — Meta-gated.** Meta rejects free-text WhatsApp outside the 24h window, so this only matters once the live Meta WhatsApp path is enabled (which is itself externally blocked on Meta business verification). When wiring it: in `shouldUseTemplate()` (or a windowed variant) query the latest inbound `EnquiryMessage` for the customer and return `true` when it is older than 24h; the template path already exists and is exercised by demo mode + the Phase 46 e2e. No schema change needed. | + ## Phase 45 — Feature wave: deferral backlog (2026-06-13) | ID | Severity | Description | Resolution | @@ -248,7 +255,7 @@ go-live checklist. | KI-013 | 36 | Info | Practice-config duration defaults were re-aligned to Patrick's 04/06 table (`SET DEFAULT` only). An **already-deployed** `PracticeSchedulingConfig` row is NOT rewritten (`practiceConfig.get()` upserts `update:{}`). Operators must apply the new values at `/admin/practice-config` on existing installs. | Set values once via the admin UI. | | KI-014 | 36 | Info | Vetup write-back workflows `n8n/09–11` ship **inactive** with `BLOCKED-ON-VETUP-API` placeholder nodes — they cannot run until Vetup support confirms a REST API / webhooks / iCal. `08` (emergency) + `12` (product alerts) work today once `N8N_WEBHOOK_URL` + the phone-number env vars are set. | Contact Vetup support; fill the placeholder URLs + auth then activate. | | ~~KI-015~~ | ~~36~~ | ~~Info~~ | ~~`/reports` dental-finding frequency counts **structured `ClinicalFinding` rows only** — findings captured inside `DentalChart.checklist` JSON are not aggregated.~~ | **Resolved in Phase 43 (read-time fold, no migration)** — `reportsService.getKpis` now folds checklist findings in via `lib/dental/checklist-finding-stats.ts`: the 11 checklist sections map onto `FindingCategory` (both fracture sections → FRACTURE), each section with `present: true` counts **once per chart** (section-level, not per tooth — matching `countFindings` semantics; surdents carry no tooth list), and a chart's contribution is skipped for any category already counted as one of that chart's own linked in-range `ClinicalFinding` rows (`dentalChartId` — same horse + same visit is the detectable duplicate signal; tooth-level matching isn't possible for section-level data, and a chart with both fracture sections plus one linked FRACTURE row suppresses both, a deliberate slight undercount over double-counting). Malformed/legacy checklist JSON contributes nothing and never throws. The card subtitle states the per-chart approximation. | -| KI-016 | 36 | Info | Deferred from Phase 36 (documented, not built): ~~batch "book all horses at a yard" (H)~~ **H shipped in Phase 40** — one appointment covering N horses via `specificHorses` (horse checklist + `horses` deep-link param on `/appointments/new`; entry points on the yard page and recalls yard groups). ~~opt-in vet geolocation on the planner (L)~~ **L shipped in Phase 45**. Still deferred: full-flow intake→confirmation e2e (O). | O remains for a later phase; it does not block the demo. | +| KI-016 | 36 | Info | Deferred from Phase 36 (documented, not built): ~~batch "book all horses at a yard" (H)~~ **H shipped in Phase 40** — one appointment covering N horses via `specificHorses` (horse checklist + `horses` deep-link param on `/appointments/new`; entry points on the yard page and recalls yard groups). ~~opt-in vet geolocation on the planner (L)~~ **L shipped in Phase 45**. ~~full-flow intake→confirmation e2e (O)~~ **O delivered in Phase 46** (see the Phase 46 section at the top of this file). | All of H, L, O are now shipped; nothing remains deferred under KI-016. | | KI-017 | 36 | Info | **Producer side closed in Phase 44**: the app now emits a fire-and-forget `customer-upserted` n8n event from customer create (`POST /api/customers`) and update (`PATCH /api/customers/[id]`) via `emitN8nEvent`, with payload `{ action: 'created'\|'updated', customerId, vetupOwnerId, fullName, firstName, lastName, email, mobilePhone }` — a no-op without `N8N_WEBHOOK_URL`, and emission can never block or fail the API response. The **consumer** — workflow `n8n/10-customer-to-vetup.json` — remains **inactive** with its `BLOCKED-ON-VETUP-API` placeholder (the Vetup create endpoint is unconfirmed; same blocker class as KI-014). | Once Vetup support confirms their API: fill workflow 10's placeholder URL + auth and activate it. No further app-side change is needed. | | KI-018 | 37 | Info | The recurring "click a client/horse and it errors" failure was deployed code running ahead of the prod schema (Prisma `P2022` → API 500). `.github/workflows/migrate-production.yml` now auto-runs `prisma migrate deploy` on every merge to main that touches `prisma/` — **but only once the repo secret `PRODUCTION_DATABASE_URL` is set**. The same missing secret is the root cause of the seed-workflow failure tracked as issue #190 (`seed-demo-database.yml` errors out with "No DATABASE_URL secret set"). | Set the `PRODUCTION_DATABASE_URL` repo secret once (Settings → Secrets and variables → Actions); both workflows then work. Detail pages now also show an explicit migration-hint error card instead of a silent blank page. | | KI-028 | 45 | Fixed | **"Migrate production database" failed red on `main`** (issue #245, commit `3ce0f13`, Phase 2/#244). Migration `20260612000000_inventory_stock_movements` used bare `CREATE TYPE` / `ADD COLUMN` / `CREATE TABLE`, which collide on a database historically synced with `prisma db push` (production was — see KI-018). The collision surfaced as a Postgres "already exists" error on the **first** `prisma migrate deploy`, exiting non-zero with the raw DB error rather than the `P3018` code; the workflow's retry loop only matched `P3018`, so it bailed. A later merge saw the now-blocked queue as `P3018`, auto-resolved it, and unblocked deploy — so production self-healed and is migrated/seeded, but the SQL and the workflow were both fragile. | **Fixed (this session):** (1) the migration SQL is now idempotent — enum + FK constraints guarded by `DO $$ … IF NOT EXISTS … $$`, column/table/index use `IF NOT EXISTS`, so a replay against a db-push'd or clean DB is a no-op where objects exist. (2) `migrate-production.yml`'s self-heal loop now also matches the first-pass collision class (`already exists` / `42P07` / `42710` / `42P06` / `42701`), resolves the failed migration as applied, and retries — so this failure class can't go red again. The owner action remains: keep the `PRODUCTION_DATABASE_URL` repo secret set (no secret → the workflow still SKIPS green). | diff --git a/docs/PHASE_46_KI_016_O_DEMO_WALKTHROUGH.md b/docs/PHASE_46_KI_016_O_DEMO_WALKTHROUGH.md new file mode 100644 index 00000000..eb00c828 --- /dev/null +++ b/docs/PHASE_46_KI_016_O_DEMO_WALKTHROUGH.md @@ -0,0 +1,154 @@ +# Phase 46 — KI-016 O: intake → completion demo walkthrough ("the demo spine") + +This is the rehearsed, UI-level script that walks the **same end-to-end +pipeline** the automated real-database test +(`__tests__/e2e/intake-to-completion.test.ts`) proves at the service layer: + +> inbound WhatsApp → triage → route proposal → vet approval → booking → +> confirmation → completion → follow-up regeneration + +Run it in **demo mode** (`DEMO_MODE=true`), where WhatsApp, email, Google +Maps and n8n are all simulated — no credentials required. The status +transitions, the audit rows and the follow-up creation are **real** (real +DB writes); only the external sends are mocked. + +The single most important thing to land with the audience: **the vet is the +decision-maker at every customer-visible step.** Three explicit vet gates +punctuate the otherwise-automated flow (called out below). EquiSmile is an +assistant, not an autonomous scheduler. + +--- + +## Before you start + +- App running with `DEMO_MODE=true` and the demo seed loaded + (`npm run db:seed-demo`). See `docs/DEMO_GO_LIVE_CHECKLIST.md`. +- Sign in as a vet/admin persona (e.g. Kathelijne). Persona card prefills + the email; password is the seeded temp password. +- Two useful tabs open: **`/demo`** (the one-click simulator console) and + **`/inbox`**. + +> The `/demo` console has buttons that hit the same intake services the +> real Meta webhook uses (`/api/demo/simulate-whatsapp`, +> `/api/demo/trigger-triage`, `/api/demo/generate-routes`). For a more +> "real" feel you can instead inject a single inbound message from +> **`/admin/simulator`** and drive the rest by hand — both paths exercise +> identical code. + +--- + +## Stage 1 — Intake (inbound WhatsApp becomes a structured request) + +1. Open **`/demo`**. +2. Click **Simulate WhatsApp (EN)**. This pushes a realistic Cloud-API + webhook envelope through `processWhatsAppPayload()` — exactly what Meta + would deliver in production. +3. Open **`/inbox`**. A new thread appears for the sender. Behind it the + system created an **Enquiry**, logged the inbound **EnquiryMessage**, and + created a **VisitRequest** — and ran auto-triage inline. + +*Say:* "A customer message just landed and was turned into a structured +visit request automatically — no one re-typed anything." + +## Stage 2 — Triage (urgency + completeness, automatically) + +1. Open **`/triage`**. +2. A complete, non-urgent message (yard recognised, horse count present) is + **auto-promoted to the planning pool** — it does not need to sit in the + manual triage queue. +3. Contrast with an **urgent** message (see the urgent aside below): it is + pulled out of the automated flow, flagged for review, and the vet is + paged. It will **not** appear in any route proposal. + +*Say:* "Routine work flows straight to planning. Anything urgent is held +back and escalated to the vet — automated scheduling never touches it." + +## Stage 3 — Route proposal (geographically batched day-routes) + +1. Back on **`/demo`**, click **Routes** (or use the planner UI at + **`/route-runs`**). +2. The planner pulls pooled requests, clusters them geographically, and + produces one or more **DRAFT** route runs with ordered stops. (In demo + mode geocoding + optimisation are simulated.) +3. Open **`/route-runs`** and select the new **Draft** run to see the + numbered stops, horses per stop, and the route map. + +*Say:* "EquiSmile groups nearby visits into a sensible day-route to cut +driving — but it's only a **proposal** until the vet says so." + +### ▶ VET GATE 1 — approve the route + +On the route-run detail page, click **Approve** (DRAFT → APPROVED). Nothing +gets booked or sent before this human decision. This is the +"human approval required before route proposals become confirmed +appointments" rule made concrete. + +## Stage 4 — Booking (proposal becomes appointments) + +1. On the **Approved** route run, click **Book Appointments**. +2. Each stop becomes an **Appointment** (status PROPOSED); the underlying + visit requests flip to **BOOKED**; the route run flips to **BOOKED**. + +*Say:* "Approving turns the plan into real appointments, atomically — all +stops or none." + +### ▶ VET GATE 2 — send confirmations + +Booking and confirming are a **customer-facing commitment**, so they are +vet-triggered (the booking action is VET+ gated). Sending confirmations +flips each appointment **PROPOSED → CONFIRMED** and writes a +**ConfirmationDispatch** audit row. In demo mode the bilingual WhatsApp +confirmation is rendered by the simulator and appears in the customer's +thread in **`/inbox`**. + +*Say:* "Every outbound customer message is logged, and in production goes +out on the customer's preferred channel." + +## Stage 5 — Completion + follow-up regeneration + +1. Open the appointment (via **`/my-day`** or the appointments list) and use + **Complete visit**. +2. Record the outcome notes. Tick **Follow-up required** and set a + follow-up due date. + +### ▶ VET GATE 3 — record the clinical outcome + +Completion is a clinical record the vet enters. On save: + +- a **VisitOutcome** is written; +- the appointment flips to **COMPLETED** and the visit request to + **COMPLETED**; +- because follow-up was ticked, a **new follow-up VisitRequest** is created + back in the **planning pool** (`requestType = FOLLOW_UP`), carrying the + customer, yard and the due date forward. + +*Say:* "The loop closes itself — the moment the visit is done, the next +recommended visit is already queued for planning." + +3. (Optional) Re-run **Routes** on `/demo` and show the follow-up request is + now eligible to be planned into a future day — demonstrating the spine is + a genuine cycle, not a one-shot line. + +--- + +## The three intentional vet gates (recap) + +| Gate | Where | Transition it authorises | +|------|-------|--------------------------| +| 1. Approve route | `/route-runs/[id]` → Approve | DRAFT → APPROVED (nothing books before this) | +| 2. Book + confirm | Approved run → Book Appointments / send confirmations | PROPOSED → CONFIRMED + the only outbound customer message | +| 3. Complete visit | Appointment → Complete | clinical outcome + COMPLETED + follow-up regen | + +Everything between the gates is automated; the gates are where the vet stays +the decision-maker. That balance is the product. + +--- + +## What proves this actually works (not just slides) + +The same spine is asserted **status-by-status against a real database** by +`__tests__/e2e/intake-to-completion.test.ts` (run with +`RUN_DB_E2E=1 DEMO_MODE=true npm run test:e2e`), and that test runs in CI on +a Postgres 16 service container (the `e2e` job in `.github/workflows/ci.yml`). +If any hand-off in the pipeline regresses, CI goes red. See KI-016 O in +`docs/KNOWN_ISSUES.md`. diff --git a/package.json b/package.json index 3b035786..26efa02c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:pr33": "playwright test e2e/pr33-regression.spec.ts", "check-env": "tsx scripts/check-env.ts", "write-version": "tsx scripts/write-version.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 9d27a6df..9afa6d20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,19 @@ export default defineConfig({ test: { environment: 'node', include: ['__tests__/**/*.test.{ts,tsx}'], + // Keep vitest's default ignores AND exclude the real-database + // end-to-end suite (Phase 46 — KI-016 O). The e2e suite lives under + // __tests__/e2e and is run by its own `vitest.e2e.config.ts` via + // `npm run test:e2e`. Excluding it here guarantees the mocked unit + // suite (`npm run test`) never imports it — it must not touch a real DB. + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', + '__tests__/e2e/**', + ], globals: true, // Run before any test module is imported so `lib/env.ts`'s // import-time validation has the stub `DATABASE_URL` it needs. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 00000000..b77e1a0e --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +/** + * E2E vitest config (Phase 46 — KI-016 O). + * + * Runs ONLY the real-database end-to-end suite under `__tests__/e2e/**`. + * Distinct from the default `vitest.config.ts` (the 2,800+ mocked unit + * suite, which now excludes `__tests__/e2e/**`) in three ways: + * 1. include is scoped to `__tests__/e2e/**` only; + * 2. the setup file does NOT stub DATABASE_URL — it requires a real one; + * 3. no `@vitejs/plugin-react` (these are node-only service tests). + * + * Invoke via `npm run test:e2e`. The suite self-skips unless RUN_DB_E2E is + * set, so running this config without a DB is a harmless no-op. + */ +export default defineConfig({ + test: { + environment: 'node', + include: ['__tests__/e2e/**/*.test.{ts,tsx}'], + globals: true, + setupFiles: ['./__tests__/e2e/setup.ts'], + // Real-DB stages run in strict sequence within a single file; keep the + // suite single-threaded so concurrent files can never race on shared + // fixture rows. + fileParallelism: false, + // The full spine touches the network-free demo simulator but still does + // many DB round-trips; give it generous headroom over the unit default. + testTimeout: 60_000, + hookTimeout: 60_000, + }, + resolve: { + alias: { + '@': resolve(__dirname, '.'), + }, + }, +});