Files
readest/apps/readest-app/src/__tests__/services/sync/replicaSync.test.ts
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

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

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

128 lines
4.0 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import {
__resetReplicaSyncForTests,
getReplicaSync,
initReplicaSync,
isReplicaSyncReady,
} from '@/services/sync/replicaSync';
import { __resetSettledEventsForTests } from '@/utils/event';
import { InMemoryHlcStore } from '@/libs/hlcStore';
import type { ReplicaRow, Hlc } from '@/types/replica';
import type { CursorStore } from '@/services/sync/replicaSyncManager';
const makeMemoryCursorStore = (): CursorStore => {
const map = new Map<string, Hlc>();
return {
get: (k) => map.get(k) ?? null,
set: (k, v) => {
map.set(k, v);
},
};
};
const makeFakeClient = () => ({
push: vi.fn(async (rows: ReplicaRow[]) => rows),
pull: vi.fn(async (_kind: string, _since: Hlc | null) => [] as ReplicaRow[]),
});
afterEach(() => {
__resetReplicaSyncForTests();
__resetSettledEventsForTests();
});
describe('replicaSync singleton', () => {
test('getReplicaSync returns null before init', () => {
expect(getReplicaSync()).toBe(null);
expect(isReplicaSyncReady()).toBe(false);
});
test('initReplicaSync produces a manager + hlc context', () => {
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(ctx.manager).toBeDefined();
expect(ctx.hlc).toBeDefined();
expect(ctx.deviceId).toBe('dev-a');
expect(isReplicaSyncReady()).toBe(true);
});
test('subsequent initReplicaSync calls are idempotent (return same instance)', () => {
const a = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
const b = initReplicaSync({
deviceId: 'dev-different',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(b).toBe(a);
expect(b.deviceId).toBe('dev-a');
});
test('hlc.next() persists snapshot to the injected store', () => {
const hlcStore = new InMemoryHlcStore();
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
expect(hlcStore.load()).toBe(null);
ctx.hlc.next();
const snap = hlcStore.load();
expect(snap).not.toBe(null);
expect(snap!.counter).toBeGreaterThanOrEqual(0);
});
test('hlc.observe() persists snapshot to the injected store', () => {
const hlcStore = new InMemoryHlcStore();
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
ctx.hlc.observe('fffffffffffff-00000000-dev-other' as Hlc);
expect(hlcStore.load()).not.toBe(null);
});
test('init restores from existing snapshot (counter survives reload)', () => {
const hlcStore = new InMemoryHlcStore();
// Choose a far-future ms so the wall clock won't advance past it during
// the test — that way next() bumps the counter rather than the ms.
const farFutureMs = Date.now() + 10 * 60 * 60 * 1000;
hlcStore.save({ physicalMs: farFutureMs, counter: 42 });
const ctx = initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore,
client: makeFakeClient() as never,
});
const serialized = ctx.hlc.serialize();
expect(serialized.physicalMs).toBe(farFutureMs);
expect(serialized.counter).toBe(42);
ctx.hlc.next();
expect(ctx.hlc.serialize().counter).toBe(43);
});
test('__resetReplicaSyncForTests clears the singleton', () => {
initReplicaSync({
deviceId: 'dev-a',
cursorStore: makeMemoryCursorStore(),
hlcStore: new InMemoryHlcStore(),
client: makeFakeClient() as never,
});
expect(isReplicaSyncReady()).toBe(true);
__resetReplicaSyncForTests();
expect(isReplicaSyncReady()).toBe(false);
expect(getReplicaSync()).toBe(null);
});
});