import { describe, expect, it, vi } from 'vitest'; import { ReaderApi } from '../src/api/client.ts'; import { ApiError } from '../src/api/errors.ts'; import { SyncEngine } from '../src/core/sync.ts'; import { OfflineStore, isOutboxEmpty } from '../src/store/offline.ts'; import { FakeTransport, MemoryKv, bodyText, makePlatform } from './helpers/env.ts'; const SESSION = { user: { id: 'u1', username: 'me', displayName: '我', role: 'member' as const, createdAt: 0 }, accessToken: 'access-1', accessTokenExpiresAt: Date.now() + 3_600_000, refreshToken: 'refresh-1', refreshTokenExpiresAt: Date.now() + 86_400_000, }; it('reports a failed local progress write and permits retry without losing the outbox', async () => { const kv = new MemoryKv(); const offline = new OfflineStore(kv); const progress = { bookId: 'b', locator: 'chapter:12', percentage: 0.5, chapterTitle: '12', device: 'phone', updatedAt: 20 }; vi.spyOn(kv, 'set').mockRejectedValueOnce(new Error('disk full')); await expect(offline.setProgress(progress)).rejects.toThrow('disk full'); expect(offline.outbox().progress).toEqual([progress]); await offline.setProgress(progress); const restored = new OfflineStore(kv); await restored.load(); expect(restored.progressFor('b')).toEqual(progress); }); function sessions() { const kv = new MemoryKv(); return { kv, store: { async load() { const raw = await kv.get('s'); return raw ? JSON.parse(raw) : null; }, async save(session: unknown) { await kv.set('s', JSON.stringify(session)); }, async clear() { await kv.remove('s'); }, }, }; } function build() { const transport = new FakeTransport(); const platform = makePlatform(transport); const session = sessions(); const api = new ReaderApi(platform, session.store); api.setBaseUrl('http://test.local'); const offline = new OfflineStore(platform.kv); const engine = new SyncEngine(api, offline, platform); return { transport, platform, api, offline, engine }; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } const SECOND_SESSION = { ...SESSION, user: { ...SESSION.user, id: 'u2', username: 'other' }, accessToken: 'access-other' }; describe('ReaderApi', () => { it('attaches the bearer token once a session exists', async () => { const { api, transport } = build(); transport.json(SESSION); await api.login('me', 'password12'); transport.json({ items: [], total: 0, page: 1, pageSize: 50 }); await api.listBooks(); const last = transport.requests.at(-1)!; expect(last.headers['authorization']).toBe('Bearer access-1'); }); it('does not send a token to the public endpoints', async () => { const { api, transport } = build(); transport.json({ name: 'reader', apiVersion: 1, registrationOpen: true, userCount: 0 }); await api.instance(); expect(transport.requests[0]!.headers['authorization']).toBeUndefined(); }); it('refreshes an expired token once and retries the original request', async () => { const { api, transport } = build(); transport.json(SESSION); await api.login('me', 'password12'); transport.respondWith((request) => { if (request.url.endsWith('/api/v1/auth/refresh')) { return { status: 200, headers: {}, json: { ...SESSION, accessToken: 'access-2', refreshToken: 'refresh-2' } }; } if (request.headers['authorization'] === 'Bearer access-1') { return { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } }; } return { status: 200, headers: {}, json: { items: [], total: 0, page: 1, pageSize: 50 } }; }); await api.listBooks(); const refreshCalls = transport.countMatching((request) => request.url.endsWith('/auth/refresh')); expect(refreshCalls).toBe(1); expect(transport.requests.at(-1)!.headers['authorization']).toBe('Bearer access-2'); }); it('does not refresh or retry when the token signature is invalid', async () => { // `TOKEN_INVALID` means refreshing cannot help. Retrying would burn the // single-use refresh chain on a request that can never succeed. const { api, transport } = build(); transport.json(SESSION); await api.login('me', 'password12'); transport.json({ error: { code: 'TOKEN_INVALID', message: 'bad signature' } }, 401); await expect(api.listBooks()).rejects.toMatchObject({ code: 'TOKEN_INVALID' }); expect(transport.countMatching((request) => request.url.endsWith('/auth/refresh'))).toBe(0); }); it('de-duplicates concurrent refreshes so a rotation is not burned twice', async () => { // The server rotates refresh tokens (single-use). Three parallel requests // hitting an expired token must share one refresh, or the second and third // would present a token that was already consumed and sign the reader out. const { api, transport } = build(); transport.json(SESSION); await api.login('me', 'password12'); let refreshCount = 0; let releaseRefresh = (): void => undefined; const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); transport.respondWith(async (request) => { if (request.url.endsWith('/auth/refresh')) { refreshCount += 1; await refreshGate; return { status: 200, headers: {}, json: { ...SESSION, accessToken: 'access-2', refreshToken: 'refresh-2' } }; } if (request.headers['authorization'] === 'Bearer access-1') { return { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } }; } return { status: 200, headers: {}, json: { items: [], total: 0, page: 1, pageSize: 50 } }; }); const inFlight = Promise.all([api.listBooks(), api.listBooks(), api.listBooks()]); await new Promise((resolve) => setTimeout(resolve, 0)); releaseRefresh(); await inFlight; expect(refreshCount).toBe(1); }); it('clears the session when the refresh token itself is rejected', async () => { const { api, transport } = build(); transport.json(SESSION); await api.login('me', 'password12'); transport.respondWith((request) => request.url.endsWith('/auth/refresh') ? { status: 401, headers: {}, json: { error: { code: 'REFRESH_INVALID', message: 'used' } } } : { status: 401, headers: {}, json: { error: { code: 'TOKEN_EXPIRED', message: 'expired' } } }, ); await expect(api.listBooks()).rejects.toBeInstanceOf(ApiError); expect(api.currentSession()).toBeNull(); }); it('reports an unreachable server as an offline error, not a server error', async () => { // A rejected fetch is what a LAN address that is not reachable looks like, // and the whole three-state design keys off this being `offline` so the UI // can silently fall back to cached content instead of showing an error. const transport = new FakeTransport(); transport.failWith(new TypeError('Failed to fetch')); const platform = makePlatform(transport); const session = sessions(); const api = new ReaderApi(platform, session.store); api.setBaseUrl('http://test.local'); await expect(api.instance()).rejects.toMatchObject({ kind: 'offline' }); }); it('treats a non-JSON 200 as a connectivity problem rather than crashing', async () => { // A captive portal or a misconfigured reverse proxy answers 200 with HTML. // Parsing that as JSON would throw a SyntaxError and lose the offline path. const transport = new FakeTransport(); transport.nonJson(); const platform = makePlatform(transport); const api = new ReaderApi(platform, sessions().store); api.setBaseUrl('http://test.local'); await expect(api.instance()).rejects.toBeInstanceOf(ApiError); }); }); describe('OfflineStore', () => { it('keeps the newer progress and discards a stale write', async () => { const store = new OfflineStore(new MemoryKv()); await store.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: 'a', updatedAt: 2000 }); await store.setProgress({ bookId: 'b1', locator: 'r1:0.1:c1', percentage: 0.1, chapterTitle: 'c1', device: 'a', updatedAt: 1000 }); expect(store.current.progress['b1']!.percentage).toBe(0.5); }); it('does not let a stale pull overwrite newer local progress', async () => { const store = new OfflineStore(new MemoryKv()); await store.setProgress({ bookId: 'b1', locator: 'r1:0.9:c1', percentage: 0.9, chapterTitle: 'c1', device: 'a', updatedAt: 5000 }); await store.applyPulled({ serverTime: 100, progress: [{ bookId: 'b1', locator: 'r1:0.2:c1', percentage: 0.2, chapterTitle: 'c1', device: 'b', updatedAt: 1000 }], notes: [], }); expect(store.current.progress['b1']!.percentage).toBe(0.9); }); it('records a note deletion as a pending tombstone, not a lost write', async () => { const store = new OfflineStore(new MemoryKv()); await store.upsertNotes([ { id: 'n1', bookId: 'b1', type: 'highlight', locator: 'x', text: 't', comment: '', color: '', updatedAt: 1 }, ]); await store.deleteNote('n1'); expect(store.notesFor('b1')).toHaveLength(0); expect(store.current.pendingNoteDeletes).toContain('n1'); }); it('clears a pending tombstone when the same note is written again', async () => { const store = new OfflineStore(new MemoryKv()); await store.deleteNote('n1'); await store.upsertNotes([ { id: 'n1', bookId: 'b1', type: 'note', locator: 'x', text: 't', comment: '', color: '', updatedAt: 50 }, ]); expect(store.current.pendingNoteDeletes).not.toContain('n1'); }); it('recovers from a corrupt snapshot instead of failing to start', async () => { const kv = new MemoryKv(); await kv.set('reader.offline.v1', '{not json'); const store = new OfflineStore(kv); const snapshot = await store.load(); expect(snapshot.books).toEqual({}); expect(snapshot.serverTime).toBe(0); }); it('keeps a position written while its own push was in flight', async () => { // The failure this guards against is the one this product cannot have: a lost // reading position. It happens with a "send everything, clear everything" // outbox — the reader turns a page between the request going out and the // response arriving, the response clears the whole set, and the new position // is silently never sent. A dirty-set plus a timestamp check on the way back // is what makes the window not exist. const store = new OfflineStore(new MemoryKv()); const first = { bookId: 'b1', locator: 'r1:0.10:c1', percentage: 0.1, chapterTitle: 'c1', device: 'a', updatedAt: 1000 }; await store.setProgress(first); // The batch that the engine is about to send. const batch = store.outbox(); // In flight: the reader turns the page. await store.setProgress({ ...first, locator: 'r1:0.20:c1', percentage: 0.2, updatedAt: 1100 }); await store.markDelivered(batch, 5000); expect(store.current.dirtyProgress).toContain('b1'); expect(store.outbox().progress[0]!.percentage).toBe(0.2); // The cursor is still adopted, so the next pull stays incremental. expect(store.current.serverTime).toBe(5000); }); it('clears a position that was not touched while in flight', async () => { const store = new OfflineStore(new MemoryKv()); await store.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: 'a', updatedAt: 1000 }); await store.markDelivered(store.outbox(), 1); expect(store.current.dirtyProgress).toEqual([]); expect(isOutboxEmpty(store.current)).toBe(true); }); it('does not let a delivered batch clear a different book', async () => { // Clearing by "the batch's ids" rather than by timestamps would still be // wrong here if the ids were taken from the wrong side of the call. const store = new OfflineStore(new MemoryKv()); await store.setProgress({ bookId: 'b1', locator: 'x', percentage: 0.1, chapterTitle: 'c', device: 'a', updatedAt: 1000 }); const batch = store.outbox(); await store.setProgress({ bookId: 'b2', locator: 'x', percentage: 0.2, chapterTitle: 'c', device: 'a', updatedAt: 1000 }); await store.markDelivered(batch, 1); expect(store.current.dirtyProgress).toEqual(['b2']); }); it('serialises concurrent writes so a fast reader does not lose a position', async () => { const store = new OfflineStore(new MemoryKv()); await Promise.all( Array.from({ length: 20 }, (_value, index) => store.setProgress({ bookId: `b${index}`, locator: `r1:0:s${index}`, percentage: index / 20, chapterTitle: 'x', device: 'a', updatedAt: 1000 + index, }), ), ); await store.flush(); expect(Object.keys(store.current.progress)).toHaveLength(20); }); }); describe('SyncEngine', () => { it('discards an old account pull and its finally while the new account is syncing', async () => { const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.setScope('account-a'); const oldStarted = deferred(); const oldResponse = deferred<{ status: number; headers: {}; json: unknown }>(); const newStarted = deferred(); const newResponse = deferred<{ status: number; headers: {}; json: unknown }>(); transport.respondWith((request) => { if (request.headers.authorization === 'Bearer access-1') { oldStarted.resolve(); return oldResponse.promise; } newStarted.resolve(); return newResponse.promise; }); const oldRun = engine.syncNow(); await oldStarted.promise; engine.stop(); transport.json(SECOND_SESSION); await api.login('other', 'password12'); await offline.setScope('account-b'); transport.respondWith(() => { newStarted.resolve(); return newResponse.promise; }); engine.start(); await newStarted.promise; oldResponse.resolve({ status: 200, headers: {}, json: { serverTime: 900, progress: [{ bookId: 'private-a', locator: 'private', percentage: 0, chapterTitle: '', device: 'a', updatedAt: 1 }], notes: [{ id: 'private-note', bookId: 'private-a', type: 'note', locator: '', text: 'account A secret', comment: '', color: '', updatedAt: 1 }], } }); await oldRun; expect(offline.current.progress).toEqual({}); expect(offline.current.notes).toEqual({}); expect(offline.current.serverTime).toBe(0); expect(engine.status().state).toBe('syncing'); const count = transport.requests.length; await engine.syncNow(); expect(transport.requests).toHaveLength(count); newResponse.resolve({ status: 200, headers: {}, json: { serverTime: 5, progress: [], notes: [] } }); await vi.waitFor(() => expect(engine.status().state).toBe('idle')); expect(offline.current.serverTime).toBe(5); engine.stop(); await offline.setScope('account-b'); expect(offline.current.notes).toEqual({}); }); it.each(['syncNow', 'flush'] as const)('does not acknowledge an old %s push against the new account outbox', async (method) => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.setScope('account-a'); const progress = { bookId: 'shared-id', locator: 'account A position', percentage: 0.5, chapterTitle: '', device: 'a', updatedAt: 1 }; await offline.setProgress(progress); const started = deferred(); const response = deferred<{ status: number; headers: {}; json: unknown }>(); transport.respondWith(() => { started.resolve(); return response.promise; }); const oldRun = engine[method](); await started.promise; engine.stop(); transport.json(SECOND_SESSION); await api.login('other', 'password12'); await offline.setScope('account-b'); await offline.setProgress({ ...progress, locator: 'account B position' }); platform.setOnline(false); engine.start(); response.resolve({ status: 200, headers: {}, json: { accepted: 1, rejected: 3, serverTime: 900, progress: [], notes: [] } }); await oldRun; await vi.waitFor(() => expect(engine.status().state).toBe('offline')); expect(offline.current.dirtyProgress).toEqual(['shared-id']); expect(offline.current.progress['shared-id']!.locator).toBe('account B position'); expect(offline.current.serverTime).toBe(0); expect(engine.rejected()).toBe(0); engine.stop(); }); it('stops the remaining chunks of the old account batch after switching accounts', async () => { const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.setScope('account-a'); for (let index = 0; index < 1001; index += 1) { await offline.setProgress({ bookId: `a-${index}`, locator: 'private', percentage: 0, chapterTitle: '', device: 'a', updatedAt: 1 }); } const started = deferred(); const response = deferred<{ status: number; headers: {}; json: unknown }>(); transport.respondWith(() => { started.resolve(); return response.promise; }); const oldRun = engine.syncNow(); await started.promise; engine.stop(); transport.json(SECOND_SESSION); await api.login('other', 'password12'); await offline.setScope('account-b'); transport.json({ accepted: 1, rejected: 0, serverTime: 2, progress: [], notes: [] }); response.resolve({ status: 200, headers: {}, json: { accepted: 1000, rejected: 0, serverTime: 1, progress: [], notes: [] } }); await oldRun; expect(transport.countMatching((request) => request.url === '/api/v1/sync')).toBe(1); expect(offline.current.serverTime).toBe(0); expect(offline.current.progress).toEqual({}); await offline.setScope('account-a'); expect(offline.current.dirtyProgress).toHaveLength(1001); }); it('clears account-specific note ownership when restarting for another account', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.setScope('account-a'); transport.json({ serverTime: 1, progress: [], notes: [ { id: 'n1', bookId: 'private-a', type: 'note', locator: '', text: 'private', comment: '', color: '', updatedAt: 1 }, ] }); await engine.syncNow(); engine.stop(); transport.json(SECOND_SESSION); await api.login('other', 'password12'); await offline.setScope('account-b'); await offline.deleteNote('n1'); platform.setOnline(false); engine.start(); await vi.waitFor(() => expect(engine.status().state).toBe('offline')); transport.json({ accepted: 0, rejected: 1, serverTime: 2, progress: [], notes: [] }); await engine.flush(); const sent = JSON.parse(bodyText(transport.requests.at(-1)!)); expect(sent.notes[0].bookId).toBe(''); expect(sent.notes[0].bookId).not.toBe('private-a'); engine.stop(); }); it('pushes the outbox and then pulls only the delta', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); await offline.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: platform.deviceLabel, updatedAt: 1000 }); const seen: string[] = []; transport.respondWith((request) => { seen.push(`${request.method} ${request.url}`); if (request.method === 'POST' && request.url.endsWith('/api/v1/sync')) { return { status: 200, headers: {}, json: { accepted: 1, rejected: 0, serverTime: 5000, progress: [], notes: [] } }; } return { status: 200, headers: {}, json: { serverTime: 5000, progress: [], notes: [] } }; }); await engine.syncNow(); // One request, not two. // // `POST /api/v1/sync` already answers with the merged state — the server's own // comment says so — and the engine used to throw that answer away and then // issue a second request for it. Every page turn therefore cost a POST *and* a // GET carrying the same body, which is the doubling a reader sees in the // network panel as "sync 接口在不停重复". Asserting the *length* is what makes // the fix visible: a test that only checked the cursor would pass either way. expect(seen).toEqual(['POST /api/v1/sync']); expect(isOutboxEmpty(offline.current)).toBe(true); // The cursor is still adopted from the push's answer, so the next real pull is // incremental rather than a re-download of the entire server state. expect(offline.current.serverTime).toBe(5000); }); it('pulls with the incremental cursor when a poll has nothing to push', async () => { // The other half of the same contract: with an empty outbox the push has no // answer to reuse, so the pull is the real work and it must still happen — and // still be incremental. const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); const seen: string[] = []; transport.respondWith((request) => { seen.push(`${request.method} ${request.url}`); return { status: 200, headers: {}, json: { serverTime: 9000, progress: [], notes: [] } }; }); await engine.syncNow(); expect(seen).toEqual(['GET /api/v1/sync?since=0']); expect(offline.current.serverTime).toBe(9000); await engine.syncNow(); expect(seen[1]).toBe('GET /api/v1/sync?since=9000'); }); it('does not notify its listeners twice for one unchanged state', async () => { // The reported symptom: "sync 接口会不停的重复渲染". The reader screen repaints // its whole chrome tree on every notification, and a cycle announced itself // three times (syncing, idle, and a redundant explicit emit) whether or not // anything had changed — with a 30s poll keeping the cycle going while the // reader did nothing at all. const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); transport.json({ serverTime: 10, progress: [], notes: [] }); const states: string[] = []; engine.onStatus((status) => states.push(`${status.state}:${status.message}`)); await engine.syncNow(); expect(states).toEqual(['syncing:', 'idle:']); // A second cycle that ends in the same state as the first adds nothing new, so // it must add nothing to the stream either. const before = states.length; await engine.syncNow(); expect(states.slice(before)).toEqual(['syncing:', 'idle:']); }); it('folds a burst of scheduled pushes into one round trip', async () => { // The reported symptom: "sync 接口调用太过频繁". Every page turn scheduled its // own round trip, so a reader flipping through a book produced a POST and a GET // every 1.5 seconds for as long as they kept reading. vi.useFakeTimers(); try { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); transport.json({ serverTime: 10, progress: [], notes: [] }); for (let page = 0; page < 20; page += 1) { await offline.setProgress({ bookId: 'b1', locator: `r1:${page / 20}:c1`, percentage: page / 20, chapterTitle: 'c1', device: platform.deviceLabel, updatedAt: 1000 + page, }); engine.schedule(); // A page turn every 700ms, well inside the coalescing window. await vi.advanceTimersByTimeAsync(700); } await vi.advanceTimersByTimeAsync(5000); const syncPosts = transport.countMatching( (request) => request.method === 'POST' && request.url.endsWith('/api/v1/sync'), ); // Twenty page turns, one push. The bound is not "exactly one" — the window // may legitimately close more than once across 14 seconds of activity — but // it must be a handful rather than one per page. expect(syncPosts).toBeGreaterThan(0); expect(syncPosts).toBeLessThanOrEqual(6); expect(isOutboxEmpty(offline.current)).toBe(true); } finally { vi.useRealTimers(); } }); it('always makes the trailing push, so the last page turn is not lost', async () => { // The failure mode a debounce without a trailing run has: the reader turns one // final page, closes the book, and that page never reaches the server. So the // last call has to fire even though nothing followed it to re-arm the timer. vi.useFakeTimers(); try { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); transport.json({ serverTime: 42, progress: [], notes: [] }); await offline.setProgress({ bookId: 'b1', locator: 'r1:0.99:c9', percentage: 0.99, chapterTitle: 'c9', device: platform.deviceLabel, updatedAt: 9000, }); engine.schedule(); expect(engine.pendingRequest).toBe(true); await vi.advanceTimersByTimeAsync(4000); expect(engine.pendingRequest).toBe(false); expect(isOutboxEmpty(offline.current)).toBe(true); expect(transport.countMatching((r) => r.method === 'POST' && r.url.endsWith('/api/v1/sync'))).toBe(1); } finally { vi.useRealTimers(); } }); it('sends nothing at all when there is no session', async () => { // A signed-out app must not queue work it can never perform: the timer would // fire, find no session, and re-queue itself forever on a device with no // account — which is a background wake-up per few seconds on a phone. vi.useFakeTimers(); try { const { engine } = build(); engine.schedule(); expect(engine.pendingRequest).toBe(false); await vi.advanceTimersByTimeAsync(10_000); expect(engine.pendingRequest).toBe(false); } finally { vi.useRealTimers(); } }); it('drops a scheduled push when the engine is stopped', async () => { vi.useFakeTimers(); try { const { api, transport, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); engine.schedule(); expect(engine.pendingRequest).toBe(true); engine.stop(); expect(engine.pendingRequest).toBe(false); await vi.advanceTimersByTimeAsync(10_000); expect(engine.pendingRequest).toBe(false); } finally { vi.useRealTimers(); } }); it('sends nothing but a pull when the outbox is empty', async () => { const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); transport.json({ serverTime: 10, progress: [], notes: [] }); await engine.syncNow(); // Counting only /sync: the login above is itself a POST, and counting all // POSTs would make this assertion vacuous. const syncPosts = transport.countMatching( (request) => request.method === 'POST' && request.url.endsWith('/api/v1/sync'), ); expect(syncPosts).toBe(0); }); it('uses the stored cursor for the next pull', async () => { const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); transport.json({ serverTime: 777, progress: [], notes: [] }); await engine.syncNow(); transport.json({ serverTime: 999, progress: [], notes: [] }); await engine.syncNow(); const pulls = transport.requests.filter((request) => request.method === 'GET' && request.url.includes('/sync?')); expect(pulls[0]!.url).toContain('since=0'); expect(pulls[1]!.url).toContain('since=777'); }); it('degrades to offline without surfacing an error', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); platform.setOnline(false); await engine.syncNow(); expect(engine.status().state).toBe('offline'); expect(engine.status().message).toContain('离线'); }); it('does not lose the outbox when the network fails mid-push', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); await offline.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: platform.deviceLabel, updatedAt: 1000 }); transport.failWith(new Error('boom')); await engine.syncNow(); // The position must still be there to send on the next attempt. expect(isOutboxEmpty(offline.current)).toBe(false); expect(offline.current.progress['b1']).toBeDefined(); }); it('chunks a large outbox instead of sending an oversized batch', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); for (let index = 0; index < 2500; index += 1) { await offline.setProgress({ bookId: `b${index}`, locator: `r1:0:s${index}`, percentage: 0, chapterTitle: '', device: platform.deviceLabel, updatedAt: 1000, }); } const sizes: number[] = []; transport.respondWith((request) => { if (request.method === 'POST') { const body = JSON.parse(bodyText(request)) as { progress?: unknown[] }; sizes.push(body.progress?.length ?? 0); return { status: 200, headers: {}, json: { accepted: 1, rejected: 0, serverTime: 1, progress: [], notes: [] } }; } return { status: 200, headers: {}, json: { serverTime: 1, progress: [], notes: [] } }; }); await engine.syncNow(); expect(sizes.length).toBeGreaterThan(1); expect(Math.max(...sizes)).toBeLessThanOrEqual(1000); expect(sizes.reduce((sum, size) => sum + size, 0)).toBe(2500); }); it('reports rejection counts without blocking later flushes', async () => { const { api, transport, offline, engine, platform } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); await offline.setProgress({ bookId: 'gone', locator: 'x', percentage: 0, chapterTitle: '', device: platform.deviceLabel, updatedAt: 1 }); transport.respondWith((request) => request.method === 'POST' ? { status: 200, headers: {}, json: { accepted: 0, rejected: 1, serverTime: 2, progress: [], notes: [] } } : { status: 200, headers: {}, json: { serverTime: 2, progress: [], notes: [] } }, ); await engine.syncNow(); // A record the server will never accept must not stay in the outbox // forever, or every later position would queue behind it. expect(isOutboxEmpty(offline.current)).toBe(true); expect(engine.status().message).toContain('拒绝'); }); it('does nothing when there is no session', async () => { const { engine } = build(); await engine.syncNow(); expect(engine.status().state).toBe('signed-out'); }); it('does not run two syncs at once', async () => { const { api, transport, offline, engine } = build(); transport.json(SESSION); await api.login('me', 'password12'); await offline.load(); let concurrent = 0; let peak = 0; transport.respondWith(async () => { concurrent += 1; peak = Math.max(peak, concurrent); await new Promise((resolve) => setTimeout(resolve, 5)); concurrent -= 1; return { status: 200, headers: {}, json: { serverTime: 1, progress: [], notes: [] } }; }); await Promise.all([engine.syncNow(), engine.syncNow(), engine.syncNow()]); expect(peak).toBe(1); }); });