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

125 lines
4.1 KiB
TypeScript

import {
buildDictionaryCsv,
buildDocx,
buildMeetingExport,
buildMeetingMarkdown,
buildPdf,
type MeetingExportInput,
type PortableDictionaryRow,
} from '../src/features/data-portability';
const OWNER = '4f642f87-2d12-4dcf-9aa1-f4427d4078a8';
function input(): MeetingExportInput {
return {
meeting: {
id: '292a4f9d-288e-4277-aab6-b07132a80c69',
user_id: OWNER,
team_id: null,
title: '모바일 출시 점검',
status: 'completed',
started_at: '2026-08-21T00:00:00.000Z',
ended_at: '2026-08-21T01:00:00.000Z',
duration_ms: 3_600_000,
raw_transcript: null,
edited_transcript: null,
minutes_markdown: '## 요약\n\n원자적 복원을 검증했다.',
minutes_json: null,
stt_model: 'whisper-large-v3',
llm_model: 'gpt-5',
stt_latency_ms: 100,
llm_latency_ms: 200,
error_message: null,
audio_storage_key: null,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T01:00:00.000Z',
},
transcripts: [
{
id: '2d47b3b6-a0dc-4ce4-8cd4-37b32ef3a29c',
meeting_id: '292a4f9d-288e-4277-aab6-b07132a80c69',
segment_index: 0,
timestamp_ms: 5_000,
duration_ms: 1_000,
text: '전체 실패는 롤백합니다.',
speaker: '화자 1',
edited: false,
created_at: '2026-08-21T00:00:05.000Z',
updated_at: '2026-08-21T00:00:05.000Z',
},
],
memos: [
{
id: '8ebac53d-6bb6-4da6-973a-9d4e12e93d3c',
meeting_id: '292a4f9d-288e-4277-aab6-b07132a80c69',
user_id: OWNER,
content: '체크섬 | 재검증',
timestamp_ms: 3_000,
created_at: '2026-08-21T00:00:03.000Z',
},
],
documents: [],
};
}
function ascii(bytes: Uint8Array): string {
return String.fromCharCode(...bytes.slice(0, 32));
}
describe('meeting and tabular exports', () => {
it('builds Markdown with transcript, memo escaping, and metadata', () => {
const markdown = buildMeetingMarkdown(input());
expect(markdown).toContain('# Meeting Minutes — 모바일 출시 점검');
expect(markdown).toContain('체크섬 \\| 재검증');
expect(markdown).toContain('[00:05] 화자 1: 전체 실패는 롤백합니다.');
});
it('creates a structurally valid PDF container with Korean CID text', () => {
const pdf = buildPdf(buildMeetingMarkdown(input()));
const complete = String.fromCharCode(...pdf);
expect(ascii(pdf)).toMatch(/^%PDF-1\.4/);
expect(complete).toContain('/UniKS-UCS2-H');
expect(complete).toContain('%%EOF');
});
it('creates an uncompressed OOXML DOCX ZIP containing Unicode document XML', () => {
const docx = buildDocx(
buildMeetingMarkdown(input()),
'모바일 출시 점검',
new Date('2026-08-21T00:00:00.000Z'),
);
const complete = String.fromCharCode(...docx);
expect(ascii(docx)).toMatch(/^PK\x03\x04/);
expect(complete).toContain('word/document.xml');
expect(complete).toContain('[Content_Types].xml');
expect(complete).toContain('PK\x05\x06');
});
it('returns all four real meeting output formats', () => {
expect(buildMeetingExport(input(), 'md').mimeType).toBe('text/markdown');
expect(buildMeetingExport(input(), 'txt').mimeType).toBe('text/plain');
expect(buildMeetingExport(input(), 'pdf').bytes?.slice(0, 5)).toEqual(
Uint8Array.from([37, 80, 68, 70, 45]),
);
expect(buildMeetingExport(input(), 'docx').bytes?.slice(0, 2)).toEqual(
Uint8Array.from([80, 75]),
);
});
it('neutralizes spreadsheet formulas while preserving RFC4180 fields', () => {
const row: PortableDictionaryRow = {
id: '7e050e11-c90c-4e95-8581-d37ad687d836',
user_id: OWNER,
word: '=HYPERLINK("https://attacker.invalid")',
pronunciation: '쉼표, 따옴표 "확인"',
category: 'user',
usage_count: 0,
last_used_at: null,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
};
const csv = buildDictionaryCsv([row]);
expect(csv).toContain("'=HYPERLINK");
expect(csv).toContain('"쉼표, 따옴표 ""확인"""');
});
});