diff --git a/apps/api/package.json b/apps/api/package.json index eb6481a..e133cfa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@hono/oidc-auth": "1.8.3", "@hono/zod-validator": "0.8.0", "drizzle-orm": "0.45.2", + "fractional-indexing": "^3.2.0", "hono": "4.12.23", "ical.js": "2.2.1", "mysql2": "3.22.4", diff --git a/apps/api/src/lib/listEmitter.test.ts b/apps/api/src/lib/listEmitter.test.ts new file mode 100644 index 0000000..fb78602 --- /dev/null +++ b/apps/api/src/lib/listEmitter.test.ts @@ -0,0 +1,19 @@ +/** + * Wave-0 RED stubs for listEmitter (in-memory EventEmitter fan-out). + * + * Covers D-04: SSE fan-out MUST be scoped to who can see a list. + * These are pure-unit tests — no DB or HTTP server needed. + * + * Downstream plans implement the actual listEmitter.ts module that these stubs test. + * + * Run: pnpm --filter @familysync/api test + */ + +describe('listEmitter — scoped fan-out correctness (D-04)', () => { + it.todo('publishListEvent emits only to subscribers for the matching listId') + it.todo('publishListEvent does NOT emit to subscribers for a different listId') + it.todo('subscribeListEvents returns an unsubscribe function that stops future events') + it.todo('unsubscribed handler is not called after unsubscribe()') + it.todo('multiple subscribers for the same listId all receive the event') + it.todo('emitter handles 100+ concurrent subscribers without error (D-18 scale check)') +}) diff --git a/apps/api/src/routes/lists.test.ts b/apps/api/src/routes/lists.test.ts new file mode 100644 index 0000000..27663a4 --- /dev/null +++ b/apps/api/src/routes/lists.test.ts @@ -0,0 +1,42 @@ +/** + * Wave-0 RED stubs for the lists API router. + * + * These stubs cover LIST-01 through LIST-04 behavior. + * Downstream plans (02/03/06) replace the `it.todo` entries with real assertions + * once the routes and DB are wired. + * + * Security focus: T-04-02 — scoped fan-out (private list events must NOT reach non-owner). + * + * Run: pnpm --filter @familysync/api test + */ + +describe('GET /api/lists — LIST-01: returns only accessible lists', () => { + it.todo('returns empty array when user has no lists') + it.todo('returns lists owned by the current user') + it.todo('returns lists shared with the current user via list_shares') + it.todo('does NOT return private lists owned by another user') +}) + +describe('POST /api/lists — LIST-01: create list', () => { + it.todo('creates a list and inserts a list_shares row when isShared=true (D-01)') + it.todo('creates a private list with no list_shares row when isShared=false') + it.todo('rejects a name longer than 255 characters with 422') + it.todo('returns 401 when called without a session') +}) + +describe('PATCH /api/list-items/:id — LIST-02: per-field update', () => { + it.todo('updates only the checked field when patch body is { checked: true } (D-08)') + it.todo('updates only the text field when patch body is { text: "..." } (D-08)') + it.todo('rejects a patch body with more than one field with 422') + it.todo('returns 403 when caller does not own or share the parent list (T-04-02)') +}) + +describe('PATCH /api/list-items/:id rank — LIST-03: fractional reorder', () => { + it.todo('updates the rank field to the new fractional-indexing string') + it.todo('rejects an empty rank string with 422') +}) + +describe('SSE scoped fan-out — LIST-04 / T-04-02: private-list event isolation', () => { + it.todo('publishListEvent on a private list does NOT emit to a subscriber for a different list') + it.todo('publishListEvent on a shared list emits to all subscribers for that list') +}) diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts new file mode 100644 index 0000000..b88b0eb --- /dev/null +++ b/apps/api/test/setup.ts @@ -0,0 +1,37 @@ +/** + * Vitest global test setup for apps/api. + * + * Establishes shared test infrastructure for API tests: + * - DB pool access via the existing client.ts (DB_HOST/DB_NAME from env) + * - Per-test cleanup for list tables (truncate between tests so state is isolated) + * + * Usage: + * This file is referenced in vitest.config.ts via test.setupFiles. + * Pure-logic tests (listEmitter, fractional rank) do NOT require DB — the + * cleanup function is a no-op when the tables are empty. + * + * Environment: + * Set DB_HOST, DB_USER, DB_PASSWORD, DB_NAME in the test environment. + * Tests run against a local MariaDB dev database; production data is never touched. + */ + +import { afterEach } from 'vitest' +import { db } from '../src/db/client.js' +import { lists, listItems, listShares } from '../src/db/schema.js' + +/** + * Truncate list tables in FK-safe order after each test. + * list_items and list_shares have FK to lists; delete children first. + * Called automatically via afterEach — no per-test setup needed. + */ +afterEach(async () => { + try { + // Delete child rows first to avoid FK constraint violations + await db.delete(listItems) + await db.delete(listShares) + await db.delete(lists) + } catch { + // DB may not be available in pure-unit test runs (no DB_HOST configured). + // Swallow the error — pure-logic tests do not need cleanup. + } +}) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index f1881c1..3df779d 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'node', globals: true, + setupFiles: ['./test/setup.ts'], }, }) diff --git a/apps/pwa/package.json b/apps/pwa/package.json index f6ca74d..ff98135 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -11,6 +11,8 @@ "test": "vitest run" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@schedule-x/calendar": "4.6.0", "@schedule-x/calendar-controls": "4.6.0", "@schedule-x/event-modal": "4.6.0", @@ -18,10 +20,12 @@ "@schedule-x/react": "4.1.0", "@schedule-x/theme-default": "4.6.0", "@tanstack/react-query": "5.101.0", + "fractional-indexing": "^3.2.0", "ical.js": "2.2.1", "lucide-react": "1.17.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-router": "^7.17.0", "temporal-polyfill": "0.3.2", "vite-plugin-pwa": "^1.3.0", "zustand": "5.0.14" diff --git a/apps/pwa/src/hooks/useListSSE.test.ts b/apps/pwa/src/hooks/useListSSE.test.ts new file mode 100644 index 0000000..5a27b26 --- /dev/null +++ b/apps/pwa/src/hooks/useListSSE.test.ts @@ -0,0 +1,20 @@ +/** + * Wave-0 RED stubs for useListSSE hook. + * + * Covers D-11: bounded (capped exponential) backoff before showing the + * "disconnected / updates paused" indicator. + * + * Tests use a mock EventSource that simulates connect/disconnect scenarios. + * Downstream plans implement the actual useListSSE.ts hook. + * + * Run: pnpm --filter @familysync/pwa test + */ + +describe('useListSSE — D-11 bounded backoff', () => { + it.todo('starts connected when EventSource fires the open event') + it.todo('attempts to reconnect with exponential backoff on error') + it.todo('stops retrying after the configured max attempts and sets status to disconnected') + it.todo('resets backoff counter on successful reconnect') + it.todo('calls onMessage callback with parsed event data when a message arrives') + it.todo('cleans up EventSource on unmount') +}) diff --git a/apps/pwa/src/routes/ListDetail.test.tsx b/apps/pwa/src/routes/ListDetail.test.tsx new file mode 100644 index 0000000..da69cd3 --- /dev/null +++ b/apps/pwa/src/routes/ListDetail.test.tsx @@ -0,0 +1,19 @@ +/** + * Wave-0 RED stubs for ListDetail component. + * + * Covers D-07: optimistic update — the editing member's change shows instantly, + * then reconciles against the server (rollback on rejection). + * + * Downstream plans implement the actual ListDetail.tsx component. + * + * Run: pnpm --filter @familysync/pwa test + */ + +describe('ListDetail — D-07 optimistic update + rollback', () => { + it.todo('checking an item immediately updates the UI before server responds') + it.todo('unchecking an item immediately updates the UI before server responds') + it.todo('rolls back the checked state if the server PATCH returns an error') + it.todo('adding an item shows it in the list immediately (optimistic insert)') + it.todo('removes the optimistically-added item if the server POST returns an error') + it.todo('completed items sink to the "completed" section at the bottom (D-05)') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2787138..eebe52c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: drizzle-orm: specifier: 0.45.2 version: 0.45.2(mysql2@3.22.4(@types/node@22.19.19)) + fractional-indexing: + specifier: ^3.2.0 + version: 3.2.0 hono: specifier: 4.12.23 version: 4.12.23 @@ -59,6 +62,12 @@ importers: apps/pwa: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@schedule-x/calendar': specifier: 4.6.0 version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2) @@ -80,6 +89,9 @@ importers: '@tanstack/react-query': specifier: 5.101.0 version: 5.101.0(react@19.2.7) + fractional-indexing: + specifier: ^3.2.0 + version: 3.2.0 ical.js: specifier: 2.2.1 version: 2.2.1 @@ -92,6 +104,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + react-router: + specifier: ^7.17.0 + version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) temporal-polyfill: specifier: 0.3.2 version: 0.3.2 @@ -685,6 +700,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1773,6 +1810,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} @@ -2057,6 +2098,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -2589,6 +2634,16 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router@7.17.0: + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -2682,6 +2737,9 @@ packages: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3880,6 +3938,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -4660,6 +4743,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 @@ -4959,6 +5044,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fractional-indexing@3.2.0: {} + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -5459,6 +5546,14 @@ snapshots: react-refresh@0.17.0: {} + react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} redent@3.0.0: @@ -5605,6 +5700,8 @@ snapshots: serialize-javascript@7.0.5: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5805,8 +5902,7 @@ snapshots: transitivePeerDependencies: - supports-color - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.22.4: dependencies: