feat: exportBundle + importBundle per-file via endpoint existing
This commit is contained in:
parent
f1f0b57189
commit
796fd47800
|
|
@ -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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Blob> {
|
||||
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<string>; // 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<void> {
|
||||
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<ImportSummary> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue