d3ro-voice/apps/mobile-rn/__tests__/data-portability-schema.test.ts
2026-08-29 18:33:45 +09:00

122 lines
3.4 KiB
TypeScript

import {
createPortableArchive,
parsePortableArchive,
parsePortablePayload,
serializePortableArchive,
stableJsonStringify,
type PortablePayload,
} from '../src/features/data-portability';
const OWNER = '4f642f87-2d12-4dcf-9aa1-f4427d4078a8';
function payload(): PortablePayload {
return {
format: 'd3ro-account-portability',
schema_version: 1,
exported_at: '2026-08-21T00:00:00.000Z',
owner_id: OWNER,
source: 'mobile',
account: { profile: null, settings: null, subscription: null },
datasets: {
dictionary: [],
history: [],
meetings: [],
transcripts: [],
meeting_memos: [],
meeting_documents: [],
custom_instructions: [],
},
exclusions: [
'raw_audio',
'storage_objects',
'payment_credentials',
'push_tokens',
],
};
}
describe('data portability archive schema', () => {
it('round-trips the strict versioned archive and checksum', () => {
const canonical = stableJsonStringify(payload());
const archive = createPortableArchive(canonical);
const parsed = parsePortableArchive(
serializePortableArchive(archive),
OWNER,
);
expect(parsed.archive.checksum).toMatch(/^[0-9a-f]{64}$/);
expect(parsed.payload).toEqual(payload());
});
it('rejects a checksum-tampered payload', () => {
const archive = createPortableArchive(stableJsonStringify(payload()));
const tampered = JSON.stringify({
...archive,
canonical_payload: archive.canonical_payload.replace(
'"source":"mobile"',
'"source":"cloud"',
),
});
expect(() => parsePortableArchive(tampered, OWNER)).toThrow('checksum');
});
it('rejects unsupported fields instead of ignoring them', () => {
const invalid = {
...payload(),
storage_objects: [{ path: 'private/audio.wav' }],
};
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
'unsupported fields',
);
});
it('rejects a foreign owner before an RPC can run', () => {
const foreign = {
...payload(),
owner_id: '1d30574e-5da7-4b22-bf4a-028fd0084962',
};
const archive = createPortableArchive(stableJsonStringify(foreign));
expect(() =>
parsePortableArchive(serializePortableArchive(archive), OWNER),
).toThrow('different account');
});
it('rejects orphan meeting children', () => {
const invalid = payload();
invalid.datasets.transcripts.push({
id: '2d47b3b6-a0dc-4ce4-8cd4-37b32ef3a29c',
meeting_id: '292a4f9d-288e-4277-aab6-b07132a80c69',
segment_index: 0,
timestamp_ms: 0,
duration_ms: null,
text: 'orphan',
speaker: null,
edited: false,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
});
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
'without its parent',
);
});
it('rejects the same row id twice', () => {
const dictionary = {
id: '7e050e11-c90c-4e95-8581-d37ad687d836',
user_id: OWNER,
word: 'D3RO',
pronunciation: null,
category: 'technical' as const,
usage_count: 0,
last_used_at: null,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
};
const invalid = payload();
invalid.datasets.dictionary = [dictionary, dictionary];
expect(() => parsePortablePayload(stableJsonStringify(invalid))).toThrow(
'repeats a row id',
);
});
});