615 lines
21 KiB
TypeScript
615 lines
21 KiB
TypeScript
/**
|
||
* 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: '<svg><rect width="50" height="50"/></svg>',
|
||
},
|
||
});
|
||
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('<svg>');
|
||
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);
|
||
});
|
||
});
|