/** * Global Blocks API Tests – CRUD for folders and blocks */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; import { createServer } from '../src/server.js'; describe('Global Blocks API', () => { let app: FastifyInstance; let db: SqliteAdapter; let authToken: string; let createdFolderId: string; let subFolderId: string; let createdBlockId: 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: 'global-test@example.com', password: 'Password123!', name: 'Global Test', role: 'admin' }, }); authToken = JSON.parse(regRes.body).session.token; }); afterAll(async () => { await app.close(); db.close(); }); // ─── Auth Check ────────────────────────────────────── describe('Authentication', () => { it('should return 401 without authorization header for folders', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-folders', }); expect(response.statusCode).toBe(401); }); it('should return 401 without authorization header for blocks', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-blocks', }); expect(response.statusCode).toBe(401); }); }); // ─── Folder CRUD ───────────────────────────────────── describe('POST /api/global-folders', () => { it('should create a root folder with 201', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Möbel' }, }); expect(response.statusCode).toBe(201); const body = JSON.parse(response.body); expect(body.id).toBeDefined(); expect(body.name).toBe('Möbel'); expect(body.parent_id).toBeNull(); createdFolderId = body.id; }); it('should create a sub-folder with parent_id', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Stühle', parent_id: createdFolderId }, }); expect(response.statusCode).toBe(201); const body = JSON.parse(response.body); expect(body.name).toBe('Stühle'); expect(body.parent_id).toBe(createdFolderId); subFolderId = body.id; }); it('should reject folder without name with 400', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: {}, }); expect(response.statusCode).toBe(400); const body = JSON.parse(response.body); expect(body.error).toContain('Name is required'); }); it('should reject folder with empty name with 400', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: '' }, }); expect(response.statusCode).toBe(400); }); }); describe('GET /api/global-folders', () => { it('should list all folders', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(Array.isArray(body)).toBe(true); expect(body.length).toBeGreaterThanOrEqual(2); }); it('should list root folders (parentId=null)', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-folders?parentId=null', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(Array.isArray(body)).toBe(true); expect(body.every((f: { parent_id: string | null }) => f.parent_id === null)).toBe(true); }); it('should list sub-folders for a parent', async () => { const response = await app.inject({ method: 'GET', url: `/api/global-folders?parentId=${createdFolderId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.length).toBeGreaterThanOrEqual(1); expect(body[0].parent_id).toBe(createdFolderId); }); }); describe('PATCH /api/global-folders/:id', () => { it('should rename a folder', async () => { const response = await app.inject({ method: 'PATCH', url: `/api/global-folders/${createdFolderId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Möbel & Ausstattung' }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.name).toBe('Möbel & Ausstattung'); }); it('should reject setting parent to self with 400', async () => { const response = await app.inject({ method: 'PATCH', url: `/api/global-folders/${createdFolderId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { parent_id: createdFolderId }, }); expect(response.statusCode).toBe(400); }); it('should return 404 for non-existent folder', async () => { const response = await app.inject({ method: 'PATCH', url: '/api/global-folders/non-existent', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'New Name' }, }); expect(response.statusCode).toBe(404); }); }); // ─── Block CRUD ────────────────────────────────────── describe('POST /api/global-blocks', () => { it('should create a global block in a folder with 201', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Konferenzstuhl', folder_id: subFolderId, block_data: JSON.stringify({ type: 'chair', width: 50, height: 50 }), svg_data: '', }, }); expect(response.statusCode).toBe(201); const body = JSON.parse(response.body); expect(body.id).toBeDefined(); expect(body.name).toBe('Konferenzstuhl'); expect(body.folder_id).toBe(subFolderId); expect(body.block_data).toContain('chair'); expect(body.svg_data).toContain(''); createdBlockId = body.id; }); it('should create a global block without folder (root level)', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Root Block' }, }); expect(response.statusCode).toBe(201); const body = JSON.parse(response.body); expect(body.folder_id).toBeNull(); expect(body.block_data).toBe('{}'); }); it('should reject block without name with 400', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { folder_id: createdFolderId }, }); expect(response.statusCode).toBe(400); const body = JSON.parse(response.body); expect(body.error).toContain('Name is required'); }); it('should reject non-string block_data with 400', async () => { const response = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Bad Block', block_data: { not: 'a string' } }, }); expect(response.statusCode).toBe(400); expect(JSON.parse(response.body).error).toContain('block_data must be a JSON string'); }); }); describe('GET /api/global-blocks', () => { it('should list all global blocks', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(Array.isArray(body)).toBe(true); expect(body.length).toBeGreaterThanOrEqual(2); }); it('should list blocks for a specific folder', async () => { const response = await app.inject({ method: 'GET', url: `/api/global-blocks?folderId=${subFolderId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.length).toBeGreaterThanOrEqual(1); expect(body.every((b: { folder_id: string | null }) => b.folder_id === subFolderId)).toBe(true); }); it('should list root-level blocks (folderId=null)', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-blocks?folderId=null', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.every((b: { folder_id: string | null }) => b.folder_id === null)).toBe(true); }); }); describe('GET /api/global-blocks/:id', () => { it('should get a single global block', async () => { const response = await app.inject({ method: 'GET', url: `/api/global-blocks/${createdBlockId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.id).toBe(createdBlockId); expect(body.name).toBe('Konferenzstuhl'); }); it('should return 404 for non-existent block', async () => { const response = await app.inject({ method: 'GET', url: '/api/global-blocks/non-existent', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(404); }); }); describe('PATCH /api/global-blocks/:id', () => { it('should rename a block', async () => { const response = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${createdBlockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Updated Chair' }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.name).toBe('Updated Chair'); }); it('should move a block to a different folder', async () => { const response = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${createdBlockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { folder_id: createdFolderId }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.folder_id).toBe(createdFolderId); }); it('should update block_data', async () => { const response = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${createdBlockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { block_data: JSON.stringify({ type: 'updated', width: 60 }) }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.block_data).toContain('updated'); }); it('should return 404 for non-existent block update', async () => { const response = await app.inject({ method: 'PATCH', url: '/api/global-blocks/non-existent', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'New Name' }, }); expect(response.statusCode).toBe(404); }); }); // ─── Delete ─────────────────────────────────────────── describe('DELETE /api/global-blocks/:id', () => { it('should delete a global block', async () => { const createRes = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'To Delete' }, }); const blockId = JSON.parse(createRes.body).id; const response = await app.inject({ method: 'DELETE', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(204); }); it('should return 404 for non-existent block delete', async () => { const response = await app.inject({ method: 'DELETE', url: '/api/global-blocks/non-existent', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(404); }); }); describe('DELETE /api/global-folders/:id', () => { it('should delete a folder and cascade to sub-folders', async () => { const createRes = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Temp Folder' }, }); const folderId = JSON.parse(createRes.body).id; const subRes = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Temp Sub', parent_id: folderId }, }); const subId = JSON.parse(subRes.body).id; const response = await app.inject({ method: 'DELETE', url: `/api/global-folders/${folderId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(204); // Sub-folder should be gone (cascade) const checkSub = await app.inject({ method: 'GET', url: `/api/global-folders/${subId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(checkSub.statusCode).toBe(404); }); it('should return 404 for non-existent folder delete', async () => { const response = await app.inject({ method: 'DELETE', url: '/api/global-folders/non-existent', headers: { authorization: `Bearer ${authToken}` }, }); expect(response.statusCode).toBe(404); }); }); }); // ─── Task F1: Library-Paket Export/Import (.wcadlib) ────── describe('Global Blocks API – F1 export/import', () => { let app: FastifyInstance; let db: SqliteAdapter; let authToken: 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: 'f1-export@example.com', password: 'Password123!', name: 'F1 Test', role: 'admin' }, }); authToken = JSON.parse(regRes.body).session.token; }); afterAll(async () => { await app.close(); db.close(); }); it('GET /api/global-blocks/export liefert wcadlib-Paket mit folders+blocks', async () => { // Setup: 1 Ordner + 1 Block const folder = await app.inject({ method: 'POST', url: '/api/global-folders', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'F1-Ordner' }, }); const folderId = JSON.parse(folder.body).id; await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'F1-Block', folder_id: folderId, block_data: '{"type":"rect"}' }, }); const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: { authorization: `Bearer ${authToken}` }, }); expect(res.statusCode).toBe(200); const pkg = JSON.parse(res.body); expect(pkg.format).toBe('wcadlib'); expect(Array.isArray(pkg.folders)).toBe(true); expect(Array.isArray(pkg.blocks)).toBe(true); expect(pkg.folders.length).toBeGreaterThanOrEqual(1); expect(pkg.blocks.length).toBeGreaterThanOrEqual(1); }); it('POST /api/global-blocks/import spielt Paket mit Ordner-Mapping ein', async () => { const pkg = { format: 'wcadlib', version: 1, folders: [ { id: 'src-folder-1', name: 'Import-Ordner', parent_id: null }, ], blocks: [ { name: 'Import-Block', folder_id: 'src-folder-1', block_data: '{"type":"circle"}', svg_data: null }, { name: 'Import-Block-Root', folder_id: null, block_data: '{}', svg_data: null }, ], }; const res = await app.inject({ method: 'POST', url: '/api/global-blocks/import', headers: { authorization: `Bearer ${authToken}` }, payload: pkg, }); expect(res.statusCode, res.body).toBe(201); const body = JSON.parse(res.body); expect(body.imported_blocks).toBe(2); expect(body.imported_folders).toBe(1); // Block im importierten Ordner auffindbar: const blocks = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, }); const list = JSON.parse(blocks.body) as Array<{ name: string; folder_id: string | null }>; const importedBlock = list.find((b) => b.name === 'Import-Block'); expect(importedBlock).toBeDefined(); expect(importedBlock!.folder_id).not.toBeNull(); // Mapping erhalten }); it('Import mit falschem format liefert 400', async () => { const res = await app.inject({ method: 'POST', url: '/api/global-blocks/import', headers: { authorization: `Bearer ${authToken}` }, payload: { format: 'wrong', blocks: [] }, }); expect(res.statusCode).toBe(400); }); }); // ─── Task F2: Block-Favoriten ───────────────────────────── describe('Global Blocks API – F2 favorite', () => { let app: FastifyInstance; let db: SqliteAdapter; let authToken: string; let blockId: 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: 'fav-test@example.com', password: 'Password123!', name: 'Fav Test', role: 'admin' }, }); authToken = JSON.parse(regRes.body).session.token; const blockRes = await app.inject({ method: 'POST', url: '/api/global-blocks', headers: { authorization: `Bearer ${authToken}` }, payload: { name: 'Fav-Block', block_data: '{"type":"block"}' }, }); blockId = JSON.parse(blockRes.body).id; }); afterAll(async () => { await app.close(); db.close(); }); it('neuer Block startet mit is_favorite=false', async () => { const res = await app.inject({ method: 'GET', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(res.statusCode).toBe(200); expect(JSON.parse(res.body).is_favorite).toBe(false); }); it('PATCH is_favorite:true setzt Favorit', async () => { const res = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { is_favorite: true }, }); expect(res.statusCode).toBe(200); expect(JSON.parse(res.body).is_favorite).toBe(true); }); it('is_favorite ist persistent nach GET', async () => { const res = await app.inject({ method: 'GET', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, }); expect(res.statusCode).toBe(200); expect(JSON.parse(res.body).is_favorite).toBe(true); }); it('PATCH is_favorite:false hebt Favorit auf', async () => { const res = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { is_favorite: false }, }); expect(res.statusCode).toBe(200); expect(JSON.parse(res.body).is_favorite).toBe(false); }); it('PATCH mit nicht-boolean is_favorite liefert 400', async () => { const res = await app.inject({ method: 'PATCH', url: `/api/global-blocks/${blockId}`, headers: { authorization: `Bearer ${authToken}` }, payload: { is_favorite: 'yes' }, }); expect(res.statusCode).toBe(400); }); });