/** * Task F7 – .wcadlib ZIP-Paket Export/Import (Backend). * * ZIP-Struktur: manifest.json (format/version/exported_at), blocks.json * (Ordner + Blöcke), thumbs/ (SVG-Thumbnails je Block-ID). Export liefert * application/zip; Import parst das ZIP und spielt Ordner-Hierarchie + * Blöcke mit ID-Mapping ein. Invalides ZIP → 400. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { FastifyInstance } from 'fastify'; import JSZip from 'jszip'; import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; import { createServer } from '../src/server.js'; describe('Task F7: .wcadlib ZIP Export/Import', () => { let app: FastifyInstance; let db: SqliteAdapter; let token: string; beforeAll(async () => { db = new SqliteAdapter(':memory:'); await db.init(); app = await createServer({ db, port: 0 }); await app.ready(); const regRes = await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'f7-zip@example.com', password: 'Password123!', name: 'F7 Zip', role: 'admin' }, }); token = JSON.parse(regRes.body).session.token; }); afterAll(async () => { await app.close(); db.close(); }); async function createFixture(): Promise<{ folderId: string; blockId: string }> { const folderRes = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${token}` }, payload: { name: 'Event Set', parent_id: null }, }); const folderId = JSON.parse(folderRes.body).id; const blockRes = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${token}` }, payload: { name: 'Stuhl-Reihe', folder_id: folderId, block_data: JSON.stringify([{ id: 'e1', type: 'chair', x: 0, y: 0, width: 45, height: 45, properties: {} }]), svg_data: '', }, }); const blockId = JSON.parse(blockRes.body).id; return { folderId, blockId }; } it('GET /api/global-blocks/export-zip liefert ZIP mit manifest.json, blocks.json und thumbs/', async () => { const { blockId } = await createFixture(); const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip', headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/zip'); const zip = await JSZip.loadAsync(res.rawPayload ?? res.body); expect(zip.file('manifest.json')).toBeDefined(); expect(zip.file('blocks.json')).toBeDefined(); const manifest = JSON.parse(await zip.file('manifest.json')!.async('string')); expect(manifest.format).toBe('wcadlib-zip'); expect(manifest.version).toBe(1); expect(typeof manifest.exported_at).toBe('string'); const blocks = JSON.parse(await zip.file('blocks.json')!.async('string')); expect(blocks.folders.length).toBe(1); expect(blocks.folders[0].name).toBe('Event Set'); expect(blocks.blocks.length).toBeGreaterThanOrEqual(1); const chairBlock = blocks.blocks.find((b: { name: string }) => b.name === 'Stuhl-Reihe'); expect(chairBlock).toBeDefined(); expect(chairBlock.thumbnail).toBe(`thumbs/${blockId}.svg`); const thumb = zip.file(`thumbs/${blockId}.svg`); expect(thumb).toBeDefined(); expect(await thumb!.async('string')).toContain(' { const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip' }); expect(res.statusCode).toBe(401); }); it('POST /api/global-blocks/import-zip spielt ZIP ein (Ordner-Mapping + Blöcke + Thumb-Referenz erhalten)', async () => { const { blockId } = await createFixture(); const exportRes = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip', headers: { authorization: `Bearer ${token}` }, }); expect(exportRes.statusCode).toBe(200); const zipBuffer = exportRes.rawPayload ?? exportRes.body; const importRes = await app.inject({ method: 'POST', url: '/api/global-blocks/import-zip', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/zip', }, payload: zipBuffer, }); expect(importRes.statusCode).toBe(201); const body = JSON.parse(importRes.body); expect(body.imported_blocks).toBeGreaterThanOrEqual(1); expect(body.imported_folders).toBeGreaterThanOrEqual(1); // Verifizieren: importierter Block existiert mit Thumb-Daten const blocksRes = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: { authorization: `Bearer ${token}` }, }); const allBlocks = JSON.parse(blocksRes.body); const imported = allBlocks.filter((b: { name: string }) => b.name === 'Stuhl-Reihe'); expect(imported.length).toBeGreaterThanOrEqual(2); // Original + Import expect(imported.every((b: { svg_data: string | null }) => b.svg_data !== null)).toBe(true); }); it('Import mit Müll-Payload → 400 (kein valides ZIP)', async () => { const res = await app.inject({ method: 'POST', url: '/api/global-blocks/import-zip', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/zip', }, payload: Buffer.from('dies ist kein zip'), }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).error).toContain('ZIP'); }); it('Import-ZIP ohne blocks.json → 400', async () => { const zip = new JSZip(); zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1 })); const buf = await zip.generateAsync({ type: 'nodebuffer' }); const res = await app.inject({ method: 'POST', url: '/api/global-blocks/import-zip', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/zip', }, payload: buf, }); expect(res.statusCode).toBe(400); }); });