diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 890de44..9d09de2 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -1,6 +1,26 @@ import { describe, it, expect, vi } from 'vitest'; import { deleteEntry, renameEntry } from './contentEditor'; +// FileReader tidak tersedia di environment 'node' vitest; uploadAsset +// menggunakannya via fileToBase64. Polyfill minimal agar test jalan. +class MockFileReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsDataURL(file: Blob) { + file.arrayBuffer().then((buf) => { + const bytes = new Uint8Array(buf); + let bin = ''; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + this.result = `data:application/octet-stream;base64,${btoa(bin)}`; + this.onload?.(); + }); + } +} +if (typeof globalThis.FileReader === 'undefined') { + (globalThis as unknown as { FileReader: unknown }).FileReader = MockFileReader; +} + describe('deleteEntry', () => { it('mengirim force=true saat dipanggil dengan argumen force', async () => { const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true }) }); @@ -90,4 +110,126 @@ describe('saveDraft and publishDraft API', () => { expect(call[0]).toBe('/api/content/drafts/draft-1/publish'); expect(call[1].method).toBe('POST'); }); +}); + +describe('exportBundle', () => { + it('memanggil /api/content/export dengan credentials dan mengembalikan blob', async () => { + const { exportBundle } = await import('./contentEditor'); + const blob = new Blob(['zip-bytes'], { type: 'application/zip' }); + global.fetch = vi.fn().mockResolvedValue( + new Response(blob, { status: 200, headers: { 'Content-Type': 'application/zip' } }), + ); + + const result = await exportBundle(); + + expect(global.fetch).toHaveBeenCalledWith('/api/content/export', { credentials: 'include' }); + expect(result.size).toBeGreaterThan(0); + }); + + it('mengirim scope param saat diberikan', async () => { + const { exportBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(new Blob(['x']), { status: 200 }), + ); + + await exportBundle('content'); + + expect(global.fetch).toHaveBeenCalledWith('/api/content/export?scope=content', { credentials: 'include' }); + }); + + it('melempar error pada respons non-ok', async () => { + const { exportBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: false }), { status: 401 }), + ); + + await expect(exportBundle()).rejects.toThrow(); + }); +}); + +describe('importBundle — orkestrasi per-file', () => { + it('mengirim .md baru via tree/file, .md existing via drafts+publish, asset via upload', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/tree/folder')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/tree/file')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/publish')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/drafts')) { + return Promise.resolve(new Response(JSON.stringify({ success: true, draft_id: 'd-1' }), { status: 200 })); + } + if (url.includes('/api/content/assets/upload')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: false }), { status: 404 })); + }); + + const result = await importBundle({ + contentFiles: [ + { path: 'lesson/new.md', body: '# New\n' }, + { path: 'lesson/existing.md', body: '# Overwrite\n' }, + ], + assetFiles: [{ path: 'img/diag.png', bytes: new Uint8Array([1, 2, 3]) }], + mode: 'overwrite', + existingPaths: new Set(['lesson/existing.md']), + }); + + const calls = (global.fetch as ReturnType).mock.calls as [string, RequestInit?][]; + const bodies = calls.map(([u, i]) => [u, i?.body ? JSON.parse(String(i.body)) : null] as const); + + // folder dibuat dulu sebelum file di dalamnya + const folderIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/folder')); + const newFileIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/file')); + expect(folderIdx).toBeGreaterThanOrEqual(0); + expect(folderIdx).toBeLessThan(newFileIdx); + + // .md baru → tree/file dengan body sebagai template + expect(bodies.some(([u, b]) => u.includes('/api/content/tree/file') && b?.path === 'lesson/new.md' && b?.template === '# New\n')).toBe(true); + // .md existing → drafts lalu publish + expect(bodies.some(([u, b]) => u.includes('/api/content/drafts') && b?.target_path === 'lesson/existing.md' && b?.base_mtime === null)).toBe(true); + expect(calls.some(([u, i]) => u === '/api/content/drafts/d-1/publish' && i?.method === 'POST')).toBe(true); + // asset → upload dengan folder relatif + expect(bodies.some(([u, b]) => u.includes('/api/content/assets/upload') && b?.filename === 'diag.png' && b?.folder === 'img')).toBe(true); + + expect(result.success).toBe(true); + expect(result.imported).toBe(3); + }); + + it('mode skip tidak menyentuh file yang sudah ada (cek client-side via existingPaths)', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + + const result = await importBundle({ + contentFiles: [{ path: 'lesson/existing.md', body: '# x\n' }], + assetFiles: [], + mode: 'skip', + existingPaths: new Set(['lesson/existing.md']), + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + }); + + it('berhenti pada kegagalan pertama dan melaporkan path yang gagal', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: false, message: 'Permission denied' }), { status: 403 }), + ); + + const result = await importBundle({ + contentFiles: [{ path: 'lesson/a.md', body: '# a\n' }], + assetFiles: [], + mode: 'overwrite', + }); + + expect(result.success).toBe(false); + expect(result.failedPath).toBe('lesson/a.md'); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index 2a0b64b..bbc77f2 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -143,3 +143,111 @@ export async function uploadAsset(file: File, folder = '') { }); return res.json(); } + +// ── Bundle import/export (client-side zip, per-file transfer) ───── + +export async function exportBundle(scope: 'all' | 'content' | 'assets' = 'all'): Promise { + const url = scope === 'all' ? '/api/content/export' : `/api/content/export?scope=${scope}`; + const res = await fetch(url, { credentials: 'include' }); + if (!res.ok) { + throw new Error(`Export gagal (${res.status})`); + } + return res.blob(); +} + +export interface ImportFileSpec { + path: string; // relatif root (tanpa prefix content/ | assets/) + body?: string; // untuk .md (teks hasil decode) + bytes?: Uint8Array; // untuk asset biner +} + +export interface ImportOptions { + contentFiles: ImportFileSpec[]; + assetFiles: ImportFileSpec[]; + mode: 'skip' | 'overwrite'; + existingPaths?: Set; // path (relatif root) yang sudah ada di server + onProgress?: (done: number, total: number, currentPath: string) => void; +} + +export interface ImportSummary { + success: boolean; + imported: number; + skipped: number; + failedPath?: string; + message?: string; +} + +/** Pastikan semua folder parent dari relPath ada di server. + * POST /content/tree/folder; 409 "Folder sudah ada" dianggap sukses. */ +async function ensureFolders(root: 'content' | 'assets', relPath: string): Promise { + const parts = relPath.split('/').slice(0, -1); // buang filename + let cur = ''; + for (const part of parts) { + cur = cur ? `${cur}/${part}` : part; + const res = await createFolder(root, cur); + if (!res.success && res.message !== 'Folder sudah ada') { + throw new Error(`Gagal membuat folder ${cur}: ${res.message ?? 'unknown'}`); + } + } +} + +export async function importBundle(options: ImportOptions): Promise { + const { contentFiles, assetFiles, mode, existingPaths, onProgress } = options; + const total = contentFiles.length + assetFiles.length; + let done = 0; + let imported = 0; + let skipped = 0; + + const files: Array<{ root: 'content' | 'assets'; spec: ImportFileSpec }> = [ + ...contentFiles.map((spec) => ({ root: 'content' as const, spec })), + ...assetFiles.map((spec) => ({ root: 'assets' as const, spec })), + ]; + + for (const { root, spec } of files) { + if (mode === 'skip' && existingPaths?.has(spec.path)) { + skipped++; + onProgress?.(++done, total, spec.path); + continue; + } + try { + await ensureFolders(root, spec.path); + if (root === 'content') { + if (existingPaths?.has(spec.path)) { + // file sudah ada → jalur overwrite: draft + publish (base_mtime null = tanpa conflict check) + const draft = await saveDraft(spec.path, spec.body ?? '', null); + if (!draft.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: draft.message }; + } + const pub = await publishDraft(draft.draft_id!); + if (!pub.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: pub.message }; + } + } else { + // file baru → tree/file dengan body sebagai template + const res = await createFile(spec.path, spec.body); + if (!res.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: res.message }; + } + } + } else { + // asset → assets/upload (nama file = basename, folder = parent relatif) + const bytes = spec.bytes ?? new Uint8Array(); + const res = await uploadAsset( + new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'), + spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '', + ); + if (!res.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: res.message }; + } + } + imported++; + onProgress?.(++done, total, spec.path); + } catch (e) { + return { + success: false, imported, skipped, failedPath: spec.path, + message: e instanceof Error ? e.message : String(e), + }; + } + } + return { success: true, imported, skipped }; +}