Commit Graph
193 Commits
Author SHA1 Message Date
Lucas Berger 1652a68c51 feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
2026-06-09 13:33:04 -04:00
Lucas Berger 5a8d1efe1c test(04-06): add failing RED tests for LIST-04 SSE fan-out + bounded backoff
- API: 5 failing fan-out spy tests (subscribeListEvents receives 0 events since publishListEvent seams commented out in lists.ts)
- API: 4 D-04 scoped subscription tests (green — listAccess primitives from 04-02 already proven)
- PWA: useListSSE.test.ts — all 7 tests fail (module-not-found; hook not yet created)
- Covers: item:added/updated/deleted, list:updated/deleted fan-out + D-11 bounded backoff exhaustion + D-10 reconnect invalidation
2026-06-09 13:25:39 -04:00
Lucas Berger ef4b1157b3 test(04-05): server-side reorder ordering + rank precision tests (LIST-03, D-13)
- rank.test.ts: 100-iteration zipper mid-point insert precision test (Pitfall 2);
  rank-between-neighbors contract test; total 10 tests (was 8)
- lists.test.ts: 5 new LIST-03 ordering tests — PATCH position updates only rank
  and GET returns new ASC order; one-row write asserts other items unchanged;
  LWW (D-15): second PATCH overwrites first; T-04-07 two-field position PATCH → 400
- Note: tests use a0–a5 rank range (avoids uppercase ranks that sort differently
  under MariaDB utf8mb4_unicode_ci vs JS lexicographic order)
2026-06-09 13:17:35 -04:00
Lucas Berger 5e3151416c feat(04-04): implement item CRUD endpoints + fractional rank (LIST-02)
- Add rank.ts: rankForAppend/rankBetween wrapping fractional-indexing (D-13)
- Extend listsRouter: POST /:id/items (fractional rank at active-bottom),
  GET /:id/items (rank ASC, access-gated)
- Add listItemsRouter (mounted /api/list-items): PATCH /:itemId per-field LWW
  (exactly-one-field zod refine D-08/T-04-07), DELETE /:itemId delete-wins (D-09)
- Uncheck recomputes rank to active-bottom in same write (Open Question 2)
- All item handlers: access-gate via checkListAccess (T-04-05)
- Plan 06 SSE seam comments at each mutation handler
- All 48 tests green; typecheck passes
2026-06-09 12:55:32 -04:00
Lucas Berger b1dc9b8048 test(04-04): add failing tests for item CRUD endpoints + rank helpers
- Add rank.test.ts: unit tests for rankForAppend/rankBetween (RED - no impl yet)
- Extend lists.test.ts with item route tests: POST /:id/items, GET /:id/items,
  PATCH /list-items/:id (per-field LWW D-08), DELETE /list-items/:id (D-09)
- Import listItems from schema; add seedItem helper
- Tests cover: fractional rank assignment (D-13), exact-one-field refine (T-04-07),
  uncheck rank recompute, access gating T-04-05, delete-wins no resurrection D-09
2026-06-09 12:52:34 -04:00
Lucas Berger 9546b747d2 feat(04-03): implement listsRouter POST/GET/PATCH/DELETE /api/lists (LIST-01)
- GET /: scoped access (owner + list_shares); activeCount/doneCount per list
- POST /: auto-populates list_shares for all other members when isShared=true (D-01/D-02)
- PATCH /🆔 rename + isShared toggle; reconciles list_shares on visibility change
- DELETE /🆔 owner-only; cascade handles items/shares via FK onDelete cascade
- resolveUserId helper copied verbatim from events.ts per project convention
- zod createListSchema (name 1..255, isShared default true) + patchListSchema
- T-04-02 / T-04-05 / T-04-07 / T-04-08 mitigations applied
- listsRouter mounted at /api/lists in index.ts (after sseRouter)
- Plan 06 SSE seam comments left at every mutation handler
- [Rule 1 - Fix] zValidator returns 400 (not 422); tests corrected to match convention
- All 23 tests green; full API suite 140 passed no regressions
2026-06-09 12:38:05 -04:00
Lucas Berger 2b3d7896f1 test(04-03): add failing integration tests for lists router (LIST-01, D-01/D-02/D-04/D-06)
- GET /api/lists scoped access tests (empty, owned, shared, D-04 negative)
- GET /api/lists item count (activeCount/doneCount) assertion
- POST /api/lists shared/private create + auto list_shares + zod validation
- DELETE /api/lists/:id owner/403/404/cascade tests
- PATCH /api/lists/:id rename/share toggle/403/zod tests
- All fail 404 (router not yet mounted) — RED gate confirmed
2026-06-09 12:34:49 -04:00
Lucas Berger 9e17853d89 fix(04-02): use fileParallelism:false to prevent DB test race conditions
Replaced singleFork:true + sequence config with the simpler fileParallelism:false
which correctly serializes test file execution. The previous singleFork approach
ran tests from multiple files concurrently within one process, allowing the global
afterEach cleanup (test/setup.ts: truncates lists/listShares) to delete rows mid-test
in another file, causing intermittent FK violations (ER_DUP_ENTRY, ER_NO_REFERENCED_ROW).

fileParallelism:false runs one test file at a time so afterEach cleanup for file A
never races with insertions from file B.
2026-06-09 12:23:13 -04:00
Lucas Berger 792efeb3df feat(04-02): implement listEmitter + listAccess; all 9 tests GREEN
listEmitter.ts:
- Module-level EventEmitter singleton; setMaxListeners(200) (T-04-04)
- publishListEvent(listId, event): emits on list:${listId} channel
- subscribeListEvents(listId, handler): registers listener, returns unsub closure
- ListEvent type union: item:added/updated/deleted, list:updated/deleted
- D-04 isolation guaranteed by per-list channel keying

listAccess.ts:
- getAccessibleListIds(userId): two SELECT queries (owned + shared), Set dedupe
- Satisfies T-04-02/T-04-03: over-returning proven impossible by Test 7

listAccess.test.ts fix:
- Use randomUUID() suffix in seedUser to avoid oidc_sub unique-key collisions
  across test re-runs (users table not truncated by global afterEach)

vitest.config.ts:
- pool: 'forks' + singleFork: true to prevent FK violations from concurrent
  DB workers racing against the shared-state global afterEach cleanup
- sequence.concurrent: false as belt-and-suspenders

ioredis NOT introduced (D-18 abstraction boundary satisfied)
2026-06-09 12:20:38 -04:00
Lucas Berger 2d250afce2 test(04-02): add failing listEmitter + listAccess tests (RED gate)
- listEmitter.test.ts: 5 real assertions replacing it.todo stubs
  (Tests 1-4: scoped delivery, D-04 negative cross-list isolation, unsub, multi-handler, D-18 scale)
- listAccess.test.ts: 4 DB-backed assertions (Tests 5-8)
  (owned lists, shared via list_shares, D-04 negative private exclusion, dedupe)
- Both files fail: listEmitter.ts and listAccess.ts do not exist yet
2026-06-09 12:15:19 -04:00
Lucas Berger 60745b3281 refactor(04-01): move API list test stubs into tests/ mirror dir to match convention
The two Wave-0 RED stubs (lists.test.ts, listEmitter.test.ts) were co-located in
src/ but all existing API tests live in apps/api/tests/. Move them to tests/routes/
and tests/lib/, add explicit vitest imports to match the tests/ convention, and
update path references in downstream plans 04-02..04-06. PWA tests keep co-location
(that IS the PWA convention).
2026-06-09 12:10:31 -04:00
Lucas Berger 2f25b15949 feat(04-01): add list tables to schema and apply via generate+migrate [BLOCKING]
- Append lists, list_shares, list_items tables to Drizzle schema (schema.ts)
- lists: owner_id FK, is_shared bool default true (D-01), idx_lists_owner_id
- list_shares: list_id + user_id FKs, uniq_list_share, idx_list_shares_user_id (D-02)
- list_items: rank varchar for fractional-indexing (D-13), checked bool, composite indexes
- Generate 0000_easy_slipstream.sql (full schema baseline) + 0001_lists_schema.sql (new tables)
- Mark 0000 as applied in __drizzle_migrations (prior tables existed from manual DDL)
- Apply 0001_lists_schema.sql via db:migrate — lists/list_shares/list_items now in MariaDB
- Add vitest/globals + node to tsconfig types for test file compatibility
- NEVER used db:push (hard project constraint — drizzle-mariadb-push-unsafe)
- typecheck passes
2026-06-09 11:55:46 -04:00
Lucas Berger 39d4ec84c0 feat(04-01): install new deps + scaffold API test harness with Wave-0 RED stubs
- Add react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing to PWA
- Add fractional-indexing to API (rank generation server-side)
- ioredis NOT added (in-memory EventEmitter per RESEARCH Plan 02 justification)
- Create apps/api/test/setup.ts with afterEach DB cleanup for list tables
- Wire test.setupFiles in apps/api/vitest.config.ts
- Add 4 Wave-0 RED stub test files (LIST-01/02/03/04, D-04, D-11, D-07)
- All stubs run as todo, not import-error
2026-06-09 11:49:10 -04:00
Lucas Berger b8c186491b fix(03): IN-03 re-validate outbox payload before VEVENT build, hard-fail invalid rows 2026-06-09 11:05:50 -04:00
Lucas Berger f95760e6c6 fix(03): IN-01 document RRULE_PRESETS lossy round-trip v1 limitation 2026-06-09 11:05:50 -04:00
Lucas Berger fd13852eb9 fix(03): WR-04 rank failed/dead outbox row above done in sync-status 2026-06-09 11:04:00 -04:00
Lucas Berger 5b720ffdb8 fix(03): WR-03 log unconditional PUT/DELETE when If-Match etag is missing 2026-06-09 11:04:00 -04:00
Lucas Berger 5168920eb1 fix(03): CR-01 preserve RRULE on edit-as-move (forward source rule to create row) 2026-06-09 10:59:19 -04:00
Lucas Berger 7a48659cae fix(03): update event lookup test mocks for CR-01/CR-02 query-chain changes
The CR-01 fix appended .orderBy().limit(1) to the edit/delete event lookups
and CR-02 added .innerJoin(calendars).limit(1) to the freshest-etag re-read.
The existing test doubles terminated the mock chain at .where(), so the new
chain calls hit undefined methods → handlers caught the throw and returned 503
(events.test.ts) and the worker skipped the PUT (outboxWorker.test.ts).

Extend the mocks to match the corrected production chains. Behaviour-preserving:
mockWhereCalEvents stays the awaited terminal so etag override assertions still drive.

8 failing tests now green; full suite: api 103, pwa 141.
2026-06-09 10:51:10 -04:00
Lucas Berger 6d2fd79209 fix(03): IN-04 type resolveUserId param as Hono Context instead of any 2026-06-09 10:44:15 -04:00
Lucas Berger e29d6c1714 fix(03): IN-02 treat unmapped 4xx as hard fail (no full-backoff retry) 2026-06-09 10:43:14 -04:00
Lucas Berger 95f9d8c097 fix(03): IN-01 cache decrypted client per userId within a drain cycle 2026-06-09 10:42:41 -04:00
Lucas Berger 1c71f8c980 fix(03): WR-06 surface move-failed distinctly with re-save guidance 2026-06-09 10:41:55 -04:00
Lucas Berger 7bc129f0f3 fix(03): WR-04 start background workers only when run as entrypoint 2026-06-09 10:40:54 -04:00
Lucas Berger 22d1bc27d6 fix(03): WR-05 use resolved realpath for direct-run guard 2026-06-09 10:40:26 -04:00
Lucas Berger 5499f83782 fix(03): WR-02 make default-calendar selection deterministic (orderBy id, limit 1) 2026-06-09 10:39:28 -04:00
Lucas Berger 02aa407764 fix(03): WR-01 preserve existing RRULE on edit instead of resetting to none 2026-06-09 10:39:06 -04:00
Lucas Berger a596f520b4 fix(03): CR-02 scope freshest-etag re-read to the writing member's calendar 2026-06-09 10:36:18 -04:00
Lucas Berger 54addb1515 fix(03): CR-01 scope event edit/delete lookup to acting member's writable set 2026-06-09 10:35:51 -04:00
Lucas Berger d4d5327fc4 fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose
All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
2026-06-07 18:37:56 -04:00
Lucas Berger f700182674 fix(auth): assign first UNUSED palette color (AUTH-03 distinct colors)
Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
2026-06-07 18:14:20 -04:00
Lucas Berger 2c8f1a28af fix(auth): self-healing displayName; drop synthetic Member<sub> from storage
The legend showed 'Member 972be1a3' because Authelia does not emit
name/preferred_username/email in the ID TOKEN (only at the userinfo endpoint),
and @hono/oidc-auth reads ID-token claims only. The real fix is an Authelia
claims_policy adding those claims to id_token for the familysync client.

App-side robustness so it self-heals once Authelia is fixed (no DB surgery):
- deriveDisplayName now returns null (not a synthetic 'Member <sub>') when no
  real claim is present, so we never persist an ugly sub string; the UI degrades
  to a generic 'Member'.
- upsertUser now tracks the IdP name authoritatively: a non-null displayName that
  differs from the stored value updates the row (blank/stale 'Member …'/email →
  real name on next login). A null value never overwrites a good stored name.
2026-06-07 16:17:17 -04:00
Lucas Berger 98753d8e34 fix(broker): reconcile deletes into cache + resync before marking outbox done
Two write-path cache bugs surfaced during Gate 2 live testing:

P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.

P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.

Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
2026-06-07 16:12:43 -04:00
Lucas Berger a99ef1daae refactor(260607-l6l): extract shared deriveDisplayName helper (BUG 2 DRY)
The displayName claim-preference logic (name → preferred_username → email →
sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId.
Extract it to auth/user.ts as deriveDisplayName and use it in both call sites,
so the rule has one definition. Update the events.test.ts user.js mock to keep
the real helper (spread importActual) while stubbing only upsertUser.
2026-06-07 15:37:18 -04:00
Lucas Berger 509f4b26e0 test(260607-l6l): make BUG 1 join regression test couple to the handler
The original toSQL() regression test hand-built the joined query inside the
test body and asserted the SQL contained a join — tautological: it never
exercised the handler, so removing .innerJoin from events.ts left it green.

Replace it with two tests that issue real PATCH/DELETE requests against the
mocked select-chain (from → innerJoin → where) and assert the handler returns
202 (not 503) AND invokes the innerJoin spy. Verified RED: removing the
edit+delete joins fails both tests; GREEN with the joins present.
2026-06-07 15:34:39 -04:00
Lucas Berger 00a0454514 fix(260607-l6l): scope GET /api/events to current user + shared calendars
BUG 3: GET / had no ownership predicate — it returned all users' events.
Second household member would see other member's private events.

- Resolve currentUserId at top of GET handler (same resolveUserId helper
  as write endpoints); return 401 if unauthenticated.
- Add ownership predicate to WHERE: AND (calendars.userId = currentUserId
  OR calendars.isShared = true). Combined with and() around the existing
  date-window or() block. Mirrors the /writable-calendars idiom (D-03).
2026-06-07 15:28:54 -04:00
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.

- me.ts: derive displayName via name → preferred_username → email →
  "Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
  re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
  issue an UPDATE so already-existing blank rows are corrected on next login.

Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
2026-06-07 15:27:40 -04:00
Lucas Berger 28704132d0 fix(260607-l6l): add missing innerJoin to PATCH+DELETE event lookups
BUG 1: PATCH /:uid/edit and DELETE /:uid selected calendars.url/userId
from .from(calendarEvents) with no join, causing Drizzle to throw at
toSQL() time → 503. Added .innerJoin(calendars, ...) to both lookups,
mirroring the working GET / join idiom.

- Updated PATCH + DELETE beforeEach mocks to route through innerJoin→where
- Updated CR-01 PATCH test mock similarly
- Added regression: edit/delete lookups join calendars describe block with
  toSQL() assertions using vi.importActual (real drizzle, no DB needed)
- All 21 tests pass; typecheck clean
2026-06-07 15:25:31 -04:00
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00
Lucas Berger 431ab31fb8 fix(api): serve the full ./public tree, not just /assets/*
Root-level PWA files (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js,
icon-*.png, apple-touch-icon.png) were falling through to the index.html
catch-all and returning HTML — breaking the manifest (syntax error) and
preventing the service worker from ever registering. serveStatic('/*') serves
any existing file and calls next() for SPA routes, so index.html stays the
fallback. Registered after /health, /api/*, /callback so those still win.
2026-06-06 21:57:42 -04:00
Lucas Berger 76db63458e chore: remove operator seed script + gitignore local verification artifacts
- git rm apps/api/scripts/seed-credential.mjs (operator-only, run out-of-band;
  the credential is already seeded in the running DB)
- gitignore .playwright-cli/, gate2-*.png, and the seed script path
2026-06-06 21:42:29 -04:00
Lucas Berger 237ec493aa feat(260606-tv8-01): add guarded GET /api/login route + tests
- Register app.get('/api/login', redirect to '/') in protected-routes block
- Route placed after OIDC guard so unauthenticated nav triggers auth flow
- Add login.test.ts covering bypass and OIDC-passthrough redirect paths
2026-06-06 21:35:52 -04:00
Lucas Berger b46b25b26b chore(03): Gate 2 stack bring-up — serve PWA from API image, prod env, credential seed
- Dockerfile: build apps/pwa into the production image's ./public so the API
  serves the PWA on a single port (:3000) for the Pangolin/newt tunnel
- docker-compose.yml: set NODE_ENV=production (mount OIDC unconditionally) and
  constrain OIDC_SCOPES=openid profile email offline_access (Authelia rejected
  the empty-default's full scopes_supported with invalid_scope)
- apps/api/scripts/seed-credential.mjs: operator tool to seed member_credentials
  (encrypted Fastmail app password) out-of-band — fills the documented gap
2026-06-06 21:30:58 -04:00
Lucas Berger 09fd1f2e92 feat(03-11): GREEN — re-read freshest calendarEvents etag before update PUT (WR-02)
- In update dispatch, SELECT etag FROM calendar_events WHERE uid = row.uid before PUT
- Use fresh etag as If-Match instead of stale enqueue-time row.etag when available
- Fall back to row.etag when calendarEvents has no matching row
- D-08 conflict detection intact: genuine external changes update calendarEvents.etag
  differently from any pending row, so they still 412 correctly
2026-06-05 21:06:32 -04:00
Lucas Berger 5eb26c0e6b test(03-11): RED — fresh etag re-read before PUT to avoid spurious 412 (WR-02)
- WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag
  (fails RED: capturedEtag === 'old-etag', not 'new-etag')
- WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED)
- Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects
- Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed
2026-06-05 21:05:40 -04:00
Lucas Berger b409c09e25 feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)
- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
2026-06-05 21:01:57 -04:00
Lucas Berger 6b2cdf3683 test(03-11): RED — durable create-before-delete gating + concurrency guard (CR-04, CR-05)
- CR-04 cross-batch drain 1: sibling create 'pending' must block delete dispatch
- CR-04 cross-batch drain 2: sibling create 'done' must allow delete dispatch
- CR-04 paired-create-failed: sibling create 'failed'/'dead' marks delete failed, preserves original
- CR-05: two overlapping drain calls must invoke createCalendarEvent exactly once
2026-06-05 20:56:07 -04:00
Lucas Berger c21b040b36 feat(03-10): fail closed on bad credentials + fix backoff index + explicit randomUUID (CR-03, WR-01, WR-08)
- outboxWorker: remove empty-credential fallback; let loadClientForUser throw on error (CR-03)
- outboxWorker: fix backoff index from nextAttemptCount to row.attemptCount so first retry waits 15s not 60s (WR-01)
- events.ts: replace bare crypto.randomUUID() with import { randomUUID } from 'node:crypto' on all three handlers (WR-08)
2026-06-05 20:51:48 -04:00
Lucas Berger c178dcee0c test(03-10): add RED tests for CR-03 fail-closed creds + WR-01 backoff index
- Add mockDecryptPassword to vi.hoisted() so tests can control loadClientForUser behavior
- Add vi.mock for broker/crypto.js to enable CR-03 scenario
- Introduce wireMockChain() helper that differentiates credential vs outbox db selects
- CR-03 RED: credential-load failure must leave row pending, not call createFastmailClient('')
- WR-01 RED: first transient retry must use BACKOFF_SECONDS[0]=15s not BACKOFF_SECONDS[1]=60s
- Update FAKE_CRED_ROW so loadClientForUser can return a real credential-shaped row
2026-06-05 20:50:11 -04:00
Lucas Berger c03b47938e feat(03-10): wire buildVeventString into dispatch path + fix all-day DTEND+1 (CR-02, WR-04)
- outboxWorker: parse stored form JSON, build VCALENDAR via buildVeventString for create/update
- outboxWorker: return hardFail on payload parse error (corrupt payload never self-resolves)
- outboxWorker: import buildVeventString and RRULE_PRESETS from vevent.js
- vevent.ts: advance all-day DTEND by +1 calendar day (RFC-5545 exclusive end, WR-04 owning boundary)
2026-06-05 20:47:50 -04:00