|
| 1 | +import assert from 'node:assert/strict' |
| 2 | +import { it } from 'node:test' |
| 3 | +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' |
| 4 | + |
| 5 | +it('downloads every manifest and change page without grouping large attachments in one native response', async () => { |
| 6 | + const requests: URL[] = [] |
| 7 | + const items = [1, 2, 3].map((id) => ({ |
| 8 | + item_id: `asset-${id}`, path: `assets/${id}.jpg`, kind: 'binary', revision: 1, |
| 9 | + sha256: `hash-${id}`, byte_length: 8_700_000, media_type: 'image/jpeg', |
| 10 | + content: { |
| 11 | + encoding: 'base64', data: `fixture-${id}`, sha256: `hash-${id}`, |
| 12 | + byte_length: 8_700_000, media_type: 'image/jpeg' |
| 13 | + } |
| 14 | + })) |
| 15 | + let feed: any[] = [] |
| 16 | + const { createCloudSyncClient, CloudSyncCoordinator } = await loadMobileModule([ |
| 17 | + './src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-coordinator' |
| 18 | + ], { |
| 19 | + '@capacitor/core': { |
| 20 | + registerPlugin: () => ({}), |
| 21 | + CapacitorHttp: { |
| 22 | + request: async ({ url }: { url: string }) => { |
| 23 | + const request = new URL(url) |
| 24 | + requests.push(request) |
| 25 | + if (request.pathname.endsWith('/manifest')) { |
| 26 | + const page = Number(request.searchParams.get('page') ?? 1) |
| 27 | + const size = Number(request.searchParams.get('per_page') ?? 100) |
| 28 | + const data = items.slice((page - 1) * size, page * size) |
| 29 | + assert.ok(data.length <= 1, 'content responses must fit one attachment through the native bridge') |
| 30 | + return { status: 200, data: { data, cursor: 3, next_page: page * size < items.length ? page + 1 : null } } |
| 31 | + } |
| 32 | + assert.ok(request.pathname.endsWith('/changes')) |
| 33 | + const after = Number(request.searchParams.get('after')) |
| 34 | + const size = Number(request.searchParams.get('limit')) |
| 35 | + const remaining = feed.filter((change) => change.sequence > after) |
| 36 | + const data = remaining.slice(0, size) |
| 37 | + assert.ok(data.length <= 1, 'change responses must fit one attachment through the native bridge') |
| 38 | + return { status: 200, data: { data, cursor: feed.at(-1)?.sequence ?? 3, has_more: remaining.length > size } } |
| 39 | + } |
| 40 | + } |
| 41 | + } |
| 42 | + }) |
| 43 | + const files = new Map<string, any>() |
| 44 | + let state: any = null |
| 45 | + const sync = new CloudSyncCoordinator('vault-1', createCloudSyncClient('https://example.test', 'test-only'), { |
| 46 | + scan: async () => [...files.values()], |
| 47 | + apply: async (change: any) => { |
| 48 | + files.set(change.path, { path: change.path, kind: 'binary', content: change.content }) |
| 49 | + } |
| 50 | + }, { |
| 51 | + load: async () => state, |
| 52 | + save: async (next: any) => { state = structuredClone(next) } |
| 53 | + }, { |
| 54 | + itemId: () => assert.fail('download must not invent an item'), |
| 55 | + operationId: () => assert.fail('download must not upload unchanged files') |
| 56 | + }) |
| 57 | + |
| 58 | + await sync.sync() |
| 59 | + assert.equal(state.cursor, 3) |
| 60 | + assert.equal(files.size, 3) |
| 61 | + assert.deepEqual(requests.filter((url) => url.pathname.endsWith('/manifest')).map((url) => url.searchParams.get('page')), ['1', '2', '3']) |
| 62 | + |
| 63 | + feed = items.map((item, index) => ({ |
| 64 | + sequence: 4 + index, item_id: item.item_id, path: item.path, previous_path: item.path, |
| 65 | + type: 'upsert', revision: 2, |
| 66 | + content: { ...item.content, data: `updated-${index}`, sha256: `updated-hash-${index}` } |
| 67 | + })) |
| 68 | + requests.length = 0 |
| 69 | + await sync.sync() |
| 70 | + assert.equal(state.cursor, 6) |
| 71 | + assert.deepEqual([...files.values()].map((item) => item.content.sha256), feed.map((change) => change.content.sha256)) |
| 72 | + assert.deepEqual(requests.slice(0, 3).map((url) => url.searchParams.get('after')), ['3', '4', '5']) |
| 73 | +}) |
| 74 | + |
| 75 | +it('keeps metadata-only manifest pagination intact', async () => { |
| 76 | + const requests: URL[] = [] |
| 77 | + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { |
| 78 | + '@capacitor/core': { |
| 79 | + registerPlugin: () => ({}), |
| 80 | + CapacitorHttp: { request: async ({ url }: { url: string }) => { |
| 81 | + requests.push(new URL(url)) |
| 82 | + return { status: 200, data: { data: [], cursor: 10, next_page: null } } |
| 83 | + } } |
| 84 | + } |
| 85 | + }) |
| 86 | + const client = createCloudSyncClient('https://example.test', 'test-only') |
| 87 | + await client.manifest('vault-1', { includeContent: false, page: 2, perPage: 250 }) |
| 88 | + await client.manifest('vault-1', { includeContent: false, perPage: 1 }) |
| 89 | + assert.equal(requests[0].searchParams.get('per_page'), '250') |
| 90 | + assert.equal(requests[0].searchParams.get('page'), '2') |
| 91 | + assert.equal(requests[1].searchParams.get('per_page'), '1') |
| 92 | + assert.ok(requests.every((url) => url.searchParams.get('include_content') === 'false')) |
| 93 | +}) |
0 commit comments