MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3462

(motir-core) Story E2E — one transition fans out to all four fast-lane consumers on the engine, and a repeated invite emails once

Done
Description

The story's verification_recipe, automated: a Playwright spec proving that with the fast lane routed to the Postgres engine, one status transition still reaches all four of its consumers — and that a repeated invite still sends one email.

The scaffolds this leans on all exist

Verified on origin/main@b944dab5, because a test card that names a harness it has not opened is asserting one:

  • tests/e2e/_helpers/job-routing.ts — moves a job between lanes MID-SPEC through the file channel MOTIR_POSTGRES_JOB_IDS_FILE, cleared in afterEach. An env var could not do this: it is fixed at server boot, and jobs-flow.spec.ts proves email.send on Inngest against the same server.
  • tests/e2e/_helpers/job-worker-process.tsglobalSetup starts the engine's worker as a third process from the SHIPPED bundle (.worker/worker.mjs), and resolves on the worker's own startup line rather than a sleep. playwright.config.ts's webServer command already runs pnpm run build:worker for it.
  • tests/e2e/_helpers/email-capture.ts (waitForEmail) and _helpers/email-fault.ts — the outbox and the deterministic fault injector.

The lane runs workers: 1, so the routing file is not raced by a parallel spec.

The journey

  1. Route the fast lane. routeJobsToEngine the four work-item/transitioned consumers plus email.send, for this spec only.
  2. Drive a real transition through the UI — the board move or the status control, not a direct service call. The point is that the path a person takes still works.
  3. Assert the fan-out reached all FOUR consumers, two ways because they are different claims. In /settings/workspace/jobs — itself a real user surface — each of the four job_run rows appears and reaches succeeded. And the three consumers with user-visible effects are asserted where a person would see them: the parent's rolled-up status on the board (status-derivation), the bell entry (notification-fan-in), and the watcher email in the outbox (watcher-notifyemail.send). Four ledger rows prove the fan-out; three visible effects prove the fan-out did something.
  4. Assert sibling isolation. Arm the email fault so ONE consumer's downstream send fails, re-drive the transition, and confirm the other consumers still complete — the story's second acceptance criterion, and the failure that would otherwise be silent.
  5. Assert idempotency on the terminal action. Send the same workspace invite twice, with the same key, and confirm the outbox holds ONE email.
  6. Assert the empty state first. Read the dashboard before the transition and confirm no runs for these ids, so a later assertion cannot pass on a row that was already there.

Waits

Every wait is on an authoritative signal — a row visible in the dashboard, an outbox entry, a DOM state the app renders. No waitForTimeout. The engine's backoff is ours (1s / 2s / 4s with jitter, per MOTIR-3427's own note that this is why its journey finishes in seconds where the Inngest equivalent needs ~150s), so there is no vendor scheduler to wait out and no excuse for a sleep.

Scope boundary

A NEW spec alongside tests/e2e/jobs-postgres-engine.spec.ts rather than an edit to it. That one proves the pilot job's DLQ and replay journey on the engine (MOTIR-3427); this one proves fan-out and idempotency. Do not re-derive its DLQ coverage — the retry / dead-letter / replay path is already asserted there, and a duplicate suite is a maintenance cost with no new information.

Does NOT touch jobs-flow.spec.ts, which proves the same journey on Inngest and must keep passing against the same server — that side-by-side is the cutover switch's whole promise.

NO ACCEPTANCE VIDEO. MOTIR-3415 ships no user-observable surface of its own — the board, the bell and the jobs dashboard all keep the shape they already have — so it is a non-UI story and accepts on its tests, per the scoped acceptance-video rule. The surfaces this spec drives are the ones it uses as instruments, not ones it delivers.

Acceptance criteria

  • With the fast lane routed to the engine, one UI-driven transition produces four succeeded job_run rows — one per FAST_LANE_CONSUMER_IDS entry — visible in /settings/workspace/jobs.
  • The rolled-up parent status, the bell entry and the watcher email are each asserted at their user-visible surface.
  • With one consumer's downstream send faulted, the remaining consumers still reach succeeded; the spec asserts the survivors, not merely that nothing threw.
  • Two invites with the same idempotency key produce exactly ONE outbox entry.

    ⚠️ AMENDED 2026-08-25, in this card's own run — this criterion is UNDISCHARGEABLE AS WRITTEN, and is replaced rather than dropped. It describes a journey the product does not have. lib/services/workspaceInvitesService.ts calls generateToken() unconditionally — one call site, line 198, no reuse-existing branch — and passes that token AS idempotencyKey. So two invites to one address are two DIFFERENT invites with two different keys and CORRECTLY produce two emails. No UI path can mint the same key twice, so no E2E can assert same-key dedup.

    The story's verification_recipe sentence “send the same workspace invite twice and confirm one email” is wrong about the product for the same reason.

    The guarantee is real and IS proven, one tier down and against real Postgres: tests/jobs/engine-idempotency.test.ts (two same-key events → one queued run, including a genuinely CONCURRENT duplicate) and tests/jobs/event-cutover-story-gate.test.ts §1 (two same-key events → one delivery, read back through the operator DTO).

    Replaced by: one invite produces exactly ONE job_run row and ONE outbox entry with the fast lane on the engine — which the shipped spec asserts, and which pins that the dedup neither double-sends nor swallows a legitimate first send. Planning bug under MOTIR-1465.

  • The dashboard shows no runs for these ids before the transition.
  • The spec clears its routing in afterEach, and jobs-flow.spec.ts passes unchanged in the same run.
  • No waitForTimeout anywhere in the spec.

Context refs

  • tests/e2e/jobs-postgres-engine.spec.ts — the sibling spec, its header on why routing is per-spec, and the DLQ coverage this must not duplicate
  • tests/e2e/_helpers/job-routing.ts · job-worker-process.ts · email-capture.ts · email-fault.ts — the four helpers
  • playwright.config.ts — the webServer commands, workers: 1, and build:worker
  • lib/jobs/latencyBudget.tsFAST_LANE_CONSUMER_IDS, the four ids to assert
  • tests/e2e/_helpers/db-reset.tsadminDb, and the singleton-statement ratchet jobs-postgres-engine.spec.ts had to respect