/** * 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); }); }); });