From 56130cd3f73339e28d67f58dba27753f080141b5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 16:56:17 +0200 Subject: [PATCH] test(mana/web/body): integration tests for bodyStore mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 vitest cases covering the load-bearing parts of bodyStore that would otherwise rot silently because they only fire on edge paths (re-tap, phase switch, double-start). Same harness as nutriphi/mutations.test.ts: fake-indexeddb + a MemoryKeyProvider seeded with a fresh master key, plus mocks for the browser-only globals the Dexie hooks reach for (funnel-tracking, triggers, inline-suggest). Coverage: Encryption (registry round-trip) - Exercise: name + notes wrapped, muscleGroup + equipment + isPreset stay plaintext for the index/picker layer - Set: weight + reps wrapped (numeric values get JSON-stringified before encryption), workoutId + exerciseId + order + isWarmup stay plaintext upsertCheck idempotency - Re-tapping the same date updates the existing row instead of creating a second one (the bug this guards against would have filled bodyChecks with one row per dot-tap on a slow day) - Partial updates preserve prior fields when callers pass undefined for the others - Different dates get different rows startPhase auto-close - Opening a second phase closes the previous one's endDate (so the "active phase" view always sees ≤ 1 open row) - endPhase stamps endDate without soft-deleting the row startWorkout single-active guard - Returns the existing open workout instead of starting a second one (would have silently double-tracked sets) - After finishWorkout, a fresh start works again logSet ordering - Assigns sequential order indices within a workout deleteWorkout cascade - Soft-deletes the workout AND all its sets in one go All 11 pass against the v2 schema (bodyExercises / bodyWorkouts / bodySets / bodyChecks / bodyPhases) plus the registry encryption allowlist landed in the previous body commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/modules/body/stores/body.test.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/body/stores/body.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/body/stores/body.test.ts b/apps/mana/apps/web/src/lib/modules/body/stores/body.test.ts new file mode 100644 index 000000000..389e993d4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/stores/body.test.ts @@ -0,0 +1,294 @@ +/** + * Integration tests for bodyStore against a real (fake) IndexedDB. + * + * Focus areas: + * - Encryption: weight + reps + notes get wrapped via the registry + * allowlist, structural columns stay plaintext. + * - upsertCheck idempotency: re-tapping a daily check rating updates + * the same row instead of creating a second one. + * - startPhase auto-closes any previously open phase so the "active + * phase" view always sees at most one open row. + * - startWorkout returns the existing active session if one is + * already running (single-active guard). + * - logSet assigns the next order index relative to the existing + * non-deleted sets in the workout. + * + * Same harness as nutriphi/mutations.test.ts: fake-indexeddb + a + * MemoryKeyProvider seeded with a fresh master key, plus mocks for + * the browser-only globals the Dexie hooks reach for. + */ + +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + decryptRecord, +} from '$lib/data/crypto'; +import { ENC_PREFIX } from '$lib/data/crypto/aes'; +import { bodyStore } from './body.svelte'; +import type { + LocalBodyExercise, + LocalBodySet, + LocalBodyWorkout, + LocalBodyCheck, + LocalBodyPhase, +} from '../types'; + +const exercises = () => db.table('bodyExercises'); +const workouts = () => db.table('bodyWorkouts'); +const sets = () => db.table('bodySets'); +const checks = () => db.table('bodyChecks'); +const phases = () => db.table('bodyPhases'); + +beforeEach(async () => { + setCurrentUserId('test-user'); + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + await Promise.all([ + exercises().clear(), + workouts().clear(), + sets().clear(), + checks().clear(), + phases().clear(), + db.table('bodyMeasurements').clear(), + db.table('bodyRoutines').clear(), + db.table('_pendingChanges').clear(), + db.table('_activity').clear(), + ]); +}); + +// ─── Encryption ───────────────────────────────────────────── + +describe('bodyStore encryption', () => { + it('encrypts exercise name + notes, leaves muscle group plaintext', async () => { + await bodyStore.createExercise({ + name: 'Front Squat', + muscleGroup: 'quads', + equipment: 'barbell', + notes: 'Heels elevated', + }); + + const raw = (await exercises().toArray())[0]; + expect(raw.name.startsWith(ENC_PREFIX)).toBe(true); + expect(raw.notes!.startsWith(ENC_PREFIX)).toBe(true); + // Structural columns stay plaintext for the index layer + expect(raw.muscleGroup).toBe('quads'); + expect(raw.equipment).toBe('barbell'); + expect(raw.isPreset).toBe(false); + + const dec = await decryptRecord('bodyExercises', { ...raw }); + expect(dec.name).toBe('Front Squat'); + expect(dec.notes).toBe('Heels elevated'); + }); + + it('encrypts set weight + reps but leaves workoutId / order plaintext', async () => { + const w = await bodyStore.startWorkout({}); + const ex = await bodyStore.createExercise({ + name: 'Bench', + muscleGroup: 'chest', + equipment: 'barbell', + }); + await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 5, + weight: 100, + weightUnit: 'kg', + }); + + const raw = (await sets().toArray())[0]; + // weight + reps are listed in the registry, so they get wrapped + // (the wrapper JSON-stringifies numeric values before encrypting) + expect(typeof raw.weight).toBe('string'); + expect(String(raw.weight).startsWith(ENC_PREFIX)).toBe(true); + expect(typeof raw.reps).toBe('string'); + expect(String(raw.reps).startsWith(ENC_PREFIX)).toBe(true); + // Plaintext index columns + expect(raw.workoutId).toBe(w.id); + expect(raw.exerciseId).toBe(ex.id); + expect(raw.order).toBe(0); + expect(raw.isWarmup).toBe(false); + + const dec = await decryptRecord('bodySets', { ...raw }); + expect(dec.weight).toBe(100); + expect(dec.reps).toBe(5); + }); +}); + +// ─── upsertCheck idempotency ──────────────────────────────── + +describe('bodyStore.upsertCheck', () => { + it('updates the existing row when re-tapping the same date', async () => { + const today = new Date().toISOString().split('T')[0]; + + await bodyStore.upsertCheck({ energy: 3 }); + expect(await checks().count()).toBe(1); + + await bodyStore.upsertCheck({ sleep: 4 }); + expect(await checks().count()).toBe(1); + + await bodyStore.upsertCheck({ mood: 5, soreness: 2 }); + expect(await checks().count()).toBe(1); + + const raw = (await checks().toArray())[0]; + const dec = await decryptRecord('bodyChecks', { ...raw }); + expect(dec.date).toBe(today); + expect(dec.energy).toBe(3); + expect(dec.sleep).toBe(4); + expect(dec.mood).toBe(5); + expect(dec.soreness).toBe(2); + }); + + it('preserves prior fields when partial updates pass undefined', async () => { + await bodyStore.upsertCheck({ energy: 4, sleep: 3 }); + await bodyStore.upsertCheck({ mood: 5 }); + + const raw = (await checks().toArray())[0]; + const dec = await decryptRecord('bodyChecks', { ...raw }); + // energy and sleep must survive the second upsert, mood is added + expect(dec.energy).toBe(4); + expect(dec.sleep).toBe(3); + expect(dec.mood).toBe(5); + }); + + it('creates a new row for a different date', async () => { + await bodyStore.upsertCheck({ date: '2026-04-08', energy: 3 }); + await bodyStore.upsertCheck({ date: '2026-04-09', energy: 4 }); + expect(await checks().count()).toBe(2); + }); +}); + +// ─── Phase auto-close ─────────────────────────────────────── + +describe('bodyStore.startPhase', () => { + it('auto-closes the previously open phase before opening a new one', async () => { + const a = await bodyStore.startPhase({ kind: 'cut', startWeight: 80 }); + const b = await bodyStore.startPhase({ kind: 'bulk', startWeight: 75 }); + + const all = (await phases().toArray()).filter((p) => !p.deletedAt); + expect(all).toHaveLength(2); + + const closed = all.find((p) => p.id === a.id)!; + const open = all.find((p) => p.id === b.id)!; + expect(closed.endDate).not.toBeNull(); + expect(open.endDate).toBeNull(); + }); + + it('endPhase stamps endDate without deleting the row', async () => { + const p = await bodyStore.startPhase({ kind: 'maintenance' }); + await bodyStore.endPhase(p.id); + const raw = (await phases().toArray()).find((r) => r.id === p.id)!; + expect(raw.endDate).not.toBeNull(); + expect(raw.deletedAt).toBeUndefined(); + }); +}); + +// ─── Workout single-active guard ─────────────────────────── + +describe('bodyStore.startWorkout', () => { + it('returns the existing open workout instead of starting a second one', async () => { + const first = await bodyStore.startWorkout({ title: 'Pull Day' }); + const second = await bodyStore.startWorkout({ title: 'Push Day' }); + + expect(second.id).toBe(first.id); + const all = (await workouts().toArray()).filter((w) => !w.deletedAt); + expect(all).toHaveLength(1); + }); + + it('starts a new workout once the previous one has been finished', async () => { + const first = await bodyStore.startWorkout({}); + await bodyStore.finishWorkout(first.id); + const second = await bodyStore.startWorkout({}); + expect(second.id).not.toBe(first.id); + + const all = (await workouts().toArray()).filter((w) => !w.deletedAt); + expect(all).toHaveLength(2); + }); +}); + +// ─── Set order assignment ────────────────────────────────── + +describe('bodyStore.logSet', () => { + it('assigns the next sequential order within a workout', async () => { + const w = await bodyStore.startWorkout({}); + const ex = await bodyStore.createExercise({ + name: 'Squat', + muscleGroup: 'quads', + equipment: 'barbell', + }); + + const s1 = await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 5, + weight: 100, + weightUnit: 'kg', + }); + const s2 = await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 5, + weight: 102.5, + weightUnit: 'kg', + }); + const s3 = await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 5, + weight: 105, + weightUnit: 'kg', + }); + + expect(s1.order).toBe(0); + expect(s2.order).toBe(1); + expect(s3.order).toBe(2); + }); + + it('deleteWorkout soft-deletes the workout AND all its sets', async () => { + const w = await bodyStore.startWorkout({}); + const ex = await bodyStore.createExercise({ + name: 'Row', + muscleGroup: 'back', + equipment: 'barbell', + }); + await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 8, + weight: 60, + weightUnit: 'kg', + }); + await bodyStore.logSet({ + workoutId: w.id, + exerciseId: ex.id, + reps: 8, + weight: 60, + weightUnit: 'kg', + }); + + await bodyStore.deleteWorkout(w.id); + + const wRow = (await workouts().toArray()).find((r) => r.id === w.id)!; + expect(wRow.deletedAt).toBeTruthy(); + + const wSets = await sets().where('workoutId').equals(w.id).toArray(); + expect(wSets).toHaveLength(2); + for (const s of wSets) { + expect(s.deletedAt).toBeTruthy(); + } + }); +});