import { describe, it, expect } from 'vitest'; import { transformBookNoteToDB, transformBookNoteFromDB, transformBookConfigToDB, transformBookConfigFromDB, transformBookToDB, transformBookFromDB, } from '@/utils/transform'; import { BookConfig, BookNote, Book } from '@/types/book'; import { DBBookConfig, DBBookNote } from '@/types/records'; describe('transformBookNoteToDB with xpointer fields', () => { it('passes through xpointer0 and xpointer1', () => { const note: BookNote = { bookHash: 'abc123', metaHash: 'meta456', id: 'note1', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', xpointer0: '/body/DocFragment[2]/body/div[1]/p[1]/text().0', xpointer1: '/body/DocFragment[2]/body/div[1]/p[1]/text().50', text: 'highlighted text', note: 'my note', style: 'highlight', color: 'yellow', createdAt: 1700000000000, updatedAt: 1700000001000, }; const dbNote = transformBookNoteToDB(note, 'user1'); expect(dbNote.xpointer0).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().0'); expect(dbNote.xpointer1).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().50'); expect(dbNote.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); }); it('handles missing xpointer fields (Readest-only note)', () => { const note: BookNote = { bookHash: 'abc123', id: 'note2', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', text: 'text', note: '', createdAt: 1700000000000, updatedAt: 1700000001000, }; const dbNote = transformBookNoteToDB(note, 'user1'); expect(dbNote.xpointer0).toBeUndefined(); expect(dbNote.xpointer1).toBeUndefined(); expect(dbNote.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); }); }); describe('transformBookNoteFromDB with xpointer fields', () => { it('reads xpointer0 and xpointer1 from DB', () => { const dbNote: DBBookNote = { user_id: 'user1', book_hash: 'abc123', meta_hash: 'meta456', id: 'note1', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', xpointer0: '/body/DocFragment[2]/body/div[1]/p[1]/text().0', xpointer1: '/body/DocFragment[2]/body/div[1]/p[1]/text().50', text: 'highlighted text', note: 'my note', style: 'highlight', color: 'yellow', created_at: '2023-11-14T22:13:20.000Z', updated_at: '2023-11-14T22:13:21.000Z', }; const note = transformBookNoteFromDB(dbNote); expect(note.xpointer0).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().0'); expect(note.xpointer1).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().50'); expect(note.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); }); it('handles missing xpointer fields from DB', () => { const dbNote: DBBookNote = { user_id: 'user1', book_hash: 'abc123', id: 'note2', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', note: '', created_at: '2023-11-14T22:13:20.000Z', updated_at: '2023-11-14T22:13:21.000Z', }; const note = transformBookNoteFromDB(dbNote); expect(note.xpointer0).toBeUndefined(); expect(note.xpointer1).toBeUndefined(); }); it('defaults cfi to empty string when missing from DB (KOReader note)', () => { const dbNote: DBBookNote = { user_id: 'user1', book_hash: 'abc123', id: 'note3', type: 'annotation', xpointer0: '/body/DocFragment[1]/body/p[1]/text().0', xpointer1: '/body/DocFragment[1]/body/p[1]/text().20', note: '', created_at: '2023-11-14T22:13:20.000Z', updated_at: '2023-11-14T22:13:21.000Z', }; const note = transformBookNoteFromDB(dbNote); expect(note.cfi).toBe(''); expect(note.xpointer0).toBe('/body/DocFragment[1]/body/p[1]/text().0'); expect(note.xpointer1).toBe('/body/DocFragment[1]/body/p[1]/text().20'); }); }); describe('transformBookNote with global flag', () => { const baseNote: BookNote = { bookHash: 'abc123', id: 'note-g', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', text: 'highlighted text', note: '', style: 'highlight', color: 'yellow', createdAt: 1700000000000, updatedAt: 1700000001000, }; it('passes through global=true when serializing to DB', () => { const note: BookNote = { ...baseNote, global: true }; const dbNote = transformBookNoteToDB(note, 'user1'); expect(dbNote.global).toBe(true); }); it('passes through global=false when serializing to DB', () => { const note: BookNote = { ...baseNote, global: false }; const dbNote = transformBookNoteToDB(note, 'user1'); expect(dbNote.global).toBe(false); }); it('omits global when undefined (legacy notes)', () => { const dbNote = transformBookNoteToDB(baseNote, 'user1'); expect(dbNote.global).toBeUndefined(); }); it('reads global=true from DB', () => { const dbNote: DBBookNote = { user_id: 'user1', book_hash: 'abc123', id: 'note-g', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', text: 'highlighted text', note: '', global: true, created_at: '2023-11-14T22:13:20.000Z', updated_at: '2023-11-14T22:13:21.000Z', }; const note = transformBookNoteFromDB(dbNote); expect(note.global).toBe(true); }); it('leaves global undefined when missing from DB', () => { const dbNote: DBBookNote = { user_id: 'user1', book_hash: 'abc123', id: 'note-g', type: 'annotation', cfi: 'epubcfi(/6/4!/4/2/1:0)', text: 'highlighted text', note: '', created_at: '2023-11-14T22:13:20.000Z', updated_at: '2023-11-14T22:13:21.000Z', }; const note = transformBookNoteFromDB(dbNote); expect(note.global).toBeUndefined(); }); it('round-trips global=true through DB transform', () => { const note: BookNote = { ...baseNote, global: true }; const db = transformBookNoteToDB(note, 'user1'); const dbRecord: DBBookNote = { ...db, created_at: new Date(note.createdAt).toISOString(), updated_at: new Date(note.updatedAt).toISOString(), }; const restored = transformBookNoteFromDB(dbRecord); expect(restored.global).toBe(true); }); // Regression: an old client that has not been updated to know about // `global` will receive a note with global=true from the server, then // upsert the same row back during a later sync. The legacy client only // copies fields it knows about, so its outgoing payload omits `global`. // We simulate that here by stripping `global` from the round-tripped // BookNote and re-serializing — the resulting DB payload also omits the // column, and the server-side merge therefore preserves the existing // global=true value (the column is left untouched on UPDATE). it('legacy client round-trip does not actively clear global (column omitted)', () => { const note: BookNote = { ...baseNote, global: true }; const dbFromUpdated = transformBookNoteToDB(note, 'user1'); const dbRecord: DBBookNote = { ...dbFromUpdated, created_at: new Date(note.createdAt).toISOString(), updated_at: new Date(note.updatedAt).toISOString(), }; const restored = transformBookNoteFromDB(dbRecord); // Simulate a legacy client: it doesn't know about `global`, so the // field is dropped from the in-memory note before it gets sent back. const legacyOutgoing: BookNote = { ...restored }; delete (legacyOutgoing as { global?: boolean }).global; const legacyDb = transformBookNoteToDB(legacyOutgoing, 'user1'); expect(legacyDb.global).toBeUndefined(); // The destructure-spread inside transformBookNoteToDB assigns // global=undefined as an own property, but JSON.stringify drops keys // whose value is undefined. So the wire payload sent to Supabase has // no `global` column, and the server-side merge preserves whatever is // already stored — which is exactly what we want for legacy clients. expect(Object.hasOwn(legacyDb, 'global')).toBe(true); expect(JSON.parse(JSON.stringify(legacyDb))).not.toHaveProperty('global'); }); }); describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', () => { const baseConfig: BookConfig = { bookHash: 'hash1', updatedAt: 1700000000000, }; it('serializes rsvpPosition to JSON string in DB record', () => { const config: BookConfig = { ...baseConfig, rsvpPosition: { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }, }; const db = transformBookConfigToDB(config, 'user1'); expect(db.rsvp_position).toBe( JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }), ); }); it('omits rsvp_position when rsvpPosition is undefined', () => { const db = transformBookConfigToDB(baseConfig, 'user1'); expect(db.rsvp_position).toBeUndefined(); }); it('deserializes rsvp_position from DB record', () => { const dbConfig: DBBookConfig = { user_id: 'user1', book_hash: 'hash1', rsvp_position: JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }), updated_at: '2023-11-14T22:13:20.000Z', }; const config = transformBookConfigFromDB(dbConfig); expect(config.rsvpPosition).toEqual({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }); }); it('leaves rsvpPosition undefined when rsvp_position is absent from DB', () => { const dbConfig: DBBookConfig = { user_id: 'user1', book_hash: 'hash1', updated_at: '2023-11-14T22:13:20.000Z', }; const config = transformBookConfigFromDB(dbConfig); expect(config.rsvpPosition).toBeUndefined(); }); it('round-trips rsvpPosition through DB transform', () => { const config: BookConfig = { ...baseConfig, rsvpPosition: { cfi: 'epubcfi(/6/8!/4/2/3:5)', wordText: 'world' }, }; const db = transformBookConfigToDB(config, 'user1'); // Simulate what DB returns (updated_at as ISO string) const dbRecord: DBBookConfig = { ...db, updated_at: new Date(config.updatedAt).toISOString() }; const restored = transformBookConfigFromDB(dbRecord); expect(restored.rsvpPosition).toEqual(config.rsvpPosition); }); }); describe('transformBook readingStatus + readingStatusUpdatedAt', () => { const userId = 'user-1'; const baseBook: Book = { hash: 'h1', format: 'EPUB', title: 'T', author: 'A', createdAt: 1, updatedAt: 2, }; it('serializes abandoned status + timestamp to ISO in the DB record', () => { const ts = Date.UTC(2026, 5, 18, 12, 0, 0); const db = transformBookToDB( { ...baseBook, readingStatus: 'abandoned', readingStatusUpdatedAt: ts }, userId, ); expect(db.reading_status).toBe('abandoned'); expect(db.reading_status_updated_at).toBe(new Date(ts).toISOString()); }); it('leaves reading_status_updated_at null when unset', () => { const db = transformBookToDB({ ...baseBook, readingStatus: 'finished' }, userId); expect(db.reading_status_updated_at).toBeNull(); }); it('round-trips abandoned + timestamp back to the client shape', () => { const ts = Date.UTC(2026, 5, 18, 12, 0, 0); const db = transformBookToDB( { ...baseBook, readingStatus: 'abandoned', readingStatusUpdatedAt: ts }, userId, ); const back = transformBookFromDB(db); expect(back.readingStatus).toBe('abandoned'); expect(back.readingStatusUpdatedAt).toBe(ts); }); it('reads undefined readingStatusUpdatedAt when the DB column is null', () => { const back = transformBookFromDB({ user_id: userId, book_hash: 'h1', format: 'EPUB', title: 'T', author: 'A', reading_status: 'finished', reading_status_updated_at: null, created_at: new Date(1).toISOString(), updated_at: new Date(2).toISOString(), }); expect(back.readingStatusUpdatedAt).toBeUndefined(); }); }); describe('transformBook coverHash + coverUpdatedAt (issue #4544)', () => { const userId = 'user-1'; const baseBook: Book = { hash: 'h1', format: 'EPUB', title: 'T', author: 'A', createdAt: 1, updatedAt: 2, }; it('serializes coverHash verbatim and coverUpdatedAt to ISO', () => { const ts = Date.UTC(2026, 5, 22, 12, 0, 0); const db = transformBookToDB( { ...baseBook, coverHash: 'abcdef0123456789', coverUpdatedAt: ts }, userId, ); expect(db.cover_hash).toBe('abcdef0123456789'); expect(db.cover_updated_at).toBe(new Date(ts).toISOString()); }); it('leaves cover columns null when unset', () => { const db = transformBookToDB(baseBook, userId); expect(db.cover_hash).toBeNull(); expect(db.cover_updated_at).toBeNull(); }); it('round-trips coverHash + coverUpdatedAt back to the client shape', () => { const ts = Date.UTC(2026, 5, 22, 12, 0, 0); const db = transformBookToDB( { ...baseBook, coverHash: 'deadbeef', coverUpdatedAt: ts }, userId, ); const back = transformBookFromDB(db); expect(back.coverHash).toBe('deadbeef'); expect(back.coverUpdatedAt).toBe(ts); }); it('reads null cover fields when the DB columns are null', () => { const back = transformBookFromDB({ user_id: userId, book_hash: 'h1', format: 'EPUB', title: 'T', author: 'A', cover_hash: null, cover_updated_at: null, created_at: new Date(1).toISOString(), updated_at: new Date(2).toISOString(), }); expect(back.coverHash).toBeNull(); expect(back.coverUpdatedAt).toBeNull(); }); });