// Contract tests for the storage layer, run against the web (Dexie) repo. // // fake-indexeddb/auto polyfills IndexedDB into the Node global so Dexie works // under vitest. We test the Dexie repo as the reference implementation of the // StorageRepo contract; the native expo-sqlite repo (native-only) is NOT // imported here because expo-sqlite cannot load outside a device/simulator. // // IMPORTANT: repo.web.ts is *not* statically imported. To exercise the v1->v2 // migration we must first seed a v1-shaped DB with a raw Dexie connection, and // only THEN import repo.web (whose module-eval opens the DB at v2, triggering // the upgrade). The dynamically-imported repo is stashed in a module-level // binding so all subsequent tests reuse it. import 'fake-indexeddb/auto'; import Dexie from 'dexie'; import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { parseDraft, type TranscriptDraft, type StorageRepo, } from './index'; // Populated by the migration beforeAll (which seeds v1 then imports repo.web). let repo: StorageRepo; // The v1 record we seed: NO segment ids, NO courseId, but WITH a searchText // column (exactly the legacy shape the v1 repo persisted). const V1_ID = 't_legacy'; const v1Row = { id: V1_ID, title: 'Legacy Lecture', createdAt: 1000, updatedAt: 1000, durationSec: 42.5, modelId: 'tiny.en' as const, segmentCount: 2, segments: [ { start: 0, end: 2, text: 'legacy hello' }, { start: 2, end: 5, text: 'legacy world', confidence: 0.8 }, ], searchText: 'legacy lecture\nlegacy hello\nlegacy world', }; beforeAll(async () => { // 1) Seed a v1 DB with a raw Dexie connection (only the v1 store/schema). const v1 = new Dexie('wisp'); v1.version(1).stores({ transcripts: 'id, createdAt' }); await v1.open(); await v1.table('transcripts').put(v1Row); v1.close(); // 2) Now import repo.web, which opens 'wisp' at v2 and runs the upgrade. const mod = await import('./repo.web'); repo = mod.repo; // Stub object-URL creation so getMediaUrl returns a string under Node. globalThis.URL.createObjectURL = () => 'blob:mock'; }); // Helper: a minimal valid draft, overridable per test. function makeDraft(over: Partial = {}): TranscriptDraft { return { title: 'My Recording', durationSec: 12.5, modelId: 'tiny.en', segments: [ { start: 0, end: 2, text: 'hello world' }, { start: 2, end: 5, text: 'second segment', confidence: 0.9 }, ], ...over, }; } describe('v1 -> v2 migration (no data loss)', () => { it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => { const fetched = await repo.get(V1_ID); expect(fetched).toBeDefined(); // Identity-preserving fields survive untouched. expect(fetched!.title).toBe('Legacy Lecture'); expect(fetched!.durationSec).toBe(42.5); // Segment text/timing is identical to the v1 body... expect(fetched!.segments.map((s) => s.text)).toEqual([ 'legacy hello', 'legacy world', ]); expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end }))).toEqual([ { start: 0, end: 2 }, { start: 2, end: 5 }, ]); expect(fetched!.segments[1]!.confidence).toBe(0.8); // ...but every segment now carries a backfilled stable id. for (const s of fetched!.segments) { expect(s.id).toMatch(/^s_/); } // No courseId was set by v1 => the row is "Unsorted" (null/undefined). expect(fetched!.courseId == null).toBe(true); // And it shows up under the Unsorted bucket. const unsorted = await repo.listByCourse(null); expect(unsorted.map((m) => m.id)).toContain(V1_ID); }); }); describe('StorageRepo (Dexie web impl)', () => { // Wipe transcripts/courses/media between tests so ordering/search/course // assertions are deterministic. (Runs after the migration beforeAll.) beforeEach(async () => { const all = await repo.list(); await Promise.all(all.map((m) => repo.remove(m.id))); const courses = await repo.listCourses(); await Promise.all(courses.map((c) => repo.deleteCourse(c.id))); }); it('create -> get round-trips segments and metadata, assigns segment ids', async () => { const created = await repo.create(makeDraft()); expect(created.id).toMatch(/^t_/); expect(created.createdAt).toBeTypeOf('number'); expect(created.updatedAt).toBe(created.createdAt); expect(created.segmentCount).toBe(2); // courseId defaults to null ("Unsorted") when absent. expect(created.courseId).toBeNull(); expect(created.hasMedia).toBe(false); // Every segment got a stable id assigned on write. for (const s of created.segments) { expect(s.id).toMatch(/^s_/); } const fetched = await repo.get(created.id); expect(fetched).toBeDefined(); expect(fetched!.title).toBe('My Recording'); expect(fetched!.durationSec).toBe(12.5); expect(fetched!.modelId).toBe('tiny.en'); // Text/timing survive the round-trip exactly. expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([ { start: 0, end: 2, text: 'hello world' }, { start: 2, end: 5, text: 'second segment' }, ]); expect(fetched!.segments[1]!.confidence).toBe(0.9); }); it('get returns undefined for an unknown id', async () => { expect(await repo.get('t_nope')).toBeUndefined(); }); it('list returns metadatas newest-first (without segments)', async () => { const a = await repo.create(makeDraft({ title: 'first' })); await new Promise((r) => setTimeout(r, 2)); const b = await repo.create(makeDraft({ title: 'second' })); await new Promise((r) => setTimeout(r, 2)); const c = await repo.create(makeDraft({ title: 'third' })); const metas = await repo.list(); expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]); expect((metas[0] as unknown as Record).segments).toBeUndefined(); expect(metas[0]!.segmentCount).toBe(2); }); it('update merges title/segments/courseId and re-ids segments + re-derives search', async () => { const course = await repo.createCourse({ name: 'Physics' }); const created = await repo.create( makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }), ); const originalUpdatedAt = created.updatedAt; await new Promise((r) => setTimeout(r, 2)); const updated = await repo.update(created.id, { title: 'Brand New Title', courseId: course.id, segments: [ { start: 0, end: 1, text: 'banana' }, { start: 1, end: 2, text: 'cherry' }, ], }); expect(updated.title).toBe('Brand New Title'); expect(updated.courseId).toBe(course.id); expect(updated.segmentCount).toBe(2); expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt); // New segments got ids. for (const s of updated.segments) { expect(s.id).toMatch(/^s_/); } // Old text is no longer findable... expect(await repo.search('apple')).toHaveLength(0); // ...and the new text + new title are. expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]); expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]); }); it('update throws for an unknown id', async () => { await expect(repo.update('t_missing', { title: 'x' })).rejects.toThrow(); }); it('search matches title and segment text case-insensitively', async () => { const t = await repo.create( makeDraft({ title: 'Quarterly MEETING notes', segments: [{ start: 0, end: 3, text: 'Discuss the BUDGET forecast' }], }), ); expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]); expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]); expect(await repo.search('zzz-not-present')).toHaveLength(0); }); it('remove deletes a transcript (and its media)', async () => { const created = await repo.create(makeDraft()); await repo.putMedia(created.id, { data: new ArrayBuffer(4), mime: 'audio/mpeg' }); expect(await repo.get(created.id)).toBeDefined(); await repo.remove(created.id); expect(await repo.get(created.id)).toBeUndefined(); expect(await repo.list()).toHaveLength(0); // Media is gone too: no playable URL remains. expect(await repo.getMediaUrl(created.id)).toBeUndefined(); }); // --- courses ------------------------------------------------------------- it('listByCourse splits Unsorted vs a course', async () => { const course = await repo.createCourse({ name: 'Bio 101' }); const inCourse = await repo.create(makeDraft({ title: 'lecture', courseId: course.id })); const unsorted = await repo.create(makeDraft({ title: 'loose note' })); const courseRows = await repo.listByCourse(course.id); expect(courseRows.map((m) => m.id)).toEqual([inCourse.id]); const unsortedRows = await repo.listByCourse(null); expect(unsortedRows.map((m) => m.id)).toEqual([unsorted.id]); }); it('createCourse / listCourses (alphabetical) / updateCourse', async () => { const zebra = await repo.createCourse({ name: 'Zebra Studies', code: 'ZOO-1' }); const apple = await repo.createCourse({ name: 'Apple Theory' }); expect(apple.id).toMatch(/^c_/); expect(apple.createdAt).toBeTypeOf('number'); // Alphabetical by name. const listed = await repo.listCourses(); expect(listed.map((c) => c.name)).toEqual(['Apple Theory', 'Zebra Studies']); const renamed = await repo.updateCourse(zebra.id, { name: 'Aardvark Studies' }); expect(renamed.name).toBe('Aardvark Studies'); expect(renamed.code).toBe('ZOO-1'); // unspecified fields preserved expect(renamed.updatedAt).toBeGreaterThanOrEqual(zebra.updatedAt); const relisted = await repo.listCourses(); expect(relisted.map((c) => c.name)).toEqual(['Aardvark Studies', 'Apple Theory']); }); it('deleteCourse reassigns its transcripts to Unsorted, then removes the course', async () => { const course = await repo.createCourse({ name: 'Doomed' }); const t = await repo.create(makeDraft({ title: 'orphan-to-be', courseId: course.id })); await repo.deleteCourse(course.id); // The course is gone. expect((await repo.listCourses()).map((c) => c.id)).not.toContain(course.id); // The transcript survives, now Unsorted. const fetched = await repo.get(t.id); expect(fetched).toBeDefined(); expect(fetched!.courseId).toBeNull(); expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id); }); it('reassign moves a transcript between courses and to Unsorted', async () => { const course = await repo.createCourse({ name: 'Chemistry' }); const t = await repo.create(makeDraft()); expect(t.courseId).toBeNull(); await repo.reassign(t.id, course.id); expect((await repo.listByCourse(course.id)).map((m) => m.id)).toEqual([t.id]); await repo.reassign(t.id, null); expect((await repo.listByCourse(course.id))).toHaveLength(0); expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id); }); // --- media --------------------------------------------------------------- it('putMedia flips hasMedia true and getMediaUrl returns a url; removeMedia flips it false', async () => { const t = await repo.create(makeDraft()); expect(t.hasMedia).toBe(false); await repo.putMedia(t.id, { data: new ArrayBuffer(8), mime: 'audio/mpeg' }); const afterPut = await repo.get(t.id); expect(afterPut!.hasMedia).toBe(true); expect(await repo.getMediaUrl(t.id)).toBe('blob:mock'); await repo.removeMedia(t.id); const afterRemove = await repo.get(t.id); expect(afterRemove!.hasMedia).toBe(false); expect(await repo.getMediaUrl(t.id)).toBeUndefined(); }); // --- validation gatekeeping --------------------------------------------- it('parseDraft rejects a NaN/Infinity duration', () => { expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow(); expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow(); }); it('parseDraft rejects a non-finite segment time', () => { expect(() => parseDraft(makeDraft({ segments: [{ start: 0, end: Infinity, text: 'x' }] })), ).toThrow(); expect(() => parseDraft(makeDraft({ segments: [{ start: NaN, end: 1, text: 'x' }] })), ).toThrow(); }); it('create rejects an invalid draft (defense in depth)', async () => { await expect(repo.create(makeDraft({ durationSec: -1 }))).rejects.toThrow(); }); });