readest/apps/readest-app/src/__tests__/utils/transform.test.ts
Huang Xin 082edc204b
fix(sync): sync updated book covers across devices (#4544) (#4731)
* docs: design for syncing updated book data (cover + file) (#4544)

Cover-change sync via a content hash (coverHash = partial MD5 of cover.png)
plus a cover_updated_at field-level merge timestamp; file updates ride the
existing re-import / metaHash dedupe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): sync updated book covers across devices (#4544)

Editing a book's cover wrote cover.png locally but changed no hash (the
cover is keyed by the file hash), so peers had no signal to re-download it
and the change never propagated.

Give the cover its own content-addressed version:
- coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff
  the synced hash differs from the local one (idempotent, no churn on
  identical/re-extracted covers — compatible with the metaHash dedupe).
- coverUpdatedAt = field-level LWW timestamp so a page-turn that wins
  whole-row LWW on updated_at can't clobber a cover edit (mirrors the
  reading_status_updated_at fix for #4634).

Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads
only the cover; the server merges cover fields independently; peers
re-download on a hash diff. File updates continue to ride the existing
re-import / metaHash dedupe (changed file -> changed hash -> re-key).

Migration 016 adds cover_hash / cover_updated_at to books.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:03 +02:00

396 lines
13 KiB
TypeScript

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();
});
});