feat: global block library tree + grouping tool — 19 files, 588 tests green
This commit is contained in:
@@ -99,6 +99,23 @@ export interface DBProjectShare {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DBGlobalBlockFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DBGlobalBlock {
|
||||
id: string;
|
||||
folder_id: string | null;
|
||||
name: string;
|
||||
block_data: string;
|
||||
svg_data: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DatabaseInterface {
|
||||
init(): Promise<void>;
|
||||
close(): void;
|
||||
@@ -165,4 +182,18 @@ export interface DatabaseInterface {
|
||||
getProjectShare(id: string): DBProjectShare | null;
|
||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
|
||||
deleteProjectShare(id: string): boolean;
|
||||
|
||||
// Global Block Folders
|
||||
listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[];
|
||||
getGlobalFolder(id: string): DBGlobalBlockFolder | null;
|
||||
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder;
|
||||
updateGlobalFolder(id: string, data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder | null;
|
||||
deleteGlobalFolder(id: string): boolean;
|
||||
|
||||
// Global Blocks
|
||||
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[];
|
||||
getGlobalBlock(id: string): DBGlobalBlock | null;
|
||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock;
|
||||
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null;
|
||||
deleteGlobalBlock(id: string): boolean;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
||||
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
||||
DBNotification, DBProjectShare,
|
||||
DBGlobalBlockFolder, DBGlobalBlock,
|
||||
} from './DatabaseInterface.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -319,4 +320,81 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
deleteProjectShare(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Global Block Folders ──────────────────────────────
|
||||
listGlobalFolders(parentId?: string | null): DBGlobalBlockFolder[] {
|
||||
if (parentId === undefined) {
|
||||
return this.db.prepare('SELECT * FROM global_block_folders ORDER BY name').all() as DBGlobalBlockFolder[];
|
||||
}
|
||||
if (parentId === null) {
|
||||
return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id IS NULL ORDER BY name').all() as DBGlobalBlockFolder[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM global_block_folders WHERE parent_id = ? ORDER BY name').all(parentId) as DBGlobalBlockFolder[];
|
||||
}
|
||||
|
||||
getGlobalFolder(id: string): DBGlobalBlockFolder | null {
|
||||
return (this.db.prepare('SELECT * FROM global_block_folders WHERE id = ?').get(id) as DBGlobalBlockFolder) ?? null;
|
||||
}
|
||||
|
||||
createGlobalFolder(data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder {
|
||||
const id = data.id ?? `gfolder-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO global_block_folders (id, name, parent_id) VALUES (?, ?, ?)',
|
||||
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null);
|
||||
return this.getGlobalFolder(id)!;
|
||||
}
|
||||
|
||||
updateGlobalFolder(id: string, data: Partial<DBGlobalBlockFolder>): DBGlobalBlockFolder | null {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); }
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE global_block_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getGlobalFolder(id);
|
||||
}
|
||||
|
||||
deleteGlobalFolder(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM global_block_folders WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Global Blocks ─────────────────────────────────────
|
||||
listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] {
|
||||
if (folderId === undefined) {
|
||||
return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[];
|
||||
}
|
||||
if (folderId === null) {
|
||||
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[];
|
||||
}
|
||||
|
||||
getGlobalBlock(id: string): DBGlobalBlock | null {
|
||||
return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null;
|
||||
}
|
||||
|
||||
createGlobalBlock(data: Partial<DBGlobalBlock>): DBGlobalBlock {
|
||||
const id = data.id ?? `gblock-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null);
|
||||
return this.getGlobalBlock(id)!;
|
||||
}
|
||||
|
||||
updateGlobalBlock(id: string, data: Partial<DBGlobalBlock>): DBGlobalBlock | null {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
|
||||
if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); }
|
||||
if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
return this.getGlobalBlock(id);
|
||||
}
|
||||
|
||||
deleteGlobalBlock(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM global_blocks WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,27 @@ CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id);
|
||||
|
||||
-- ─── Global Block Folders ─────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS global_block_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (parent_id) REFERENCES global_block_folders(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Global Blocks ──────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS global_blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
folder_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
block_data TEXT NOT NULL DEFAULT '{}',
|
||||
svg_data TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_blocks_folder ON global_blocks(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_block_folders_parent ON global_block_folders(parent_id);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Global Blocks Routes – CRUD for global block folders and blocks
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||
import { requireAuth } from '../auth/authMiddleware.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||
|
||||
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// ─── Global Block Folders ──────────────────────────────
|
||||
|
||||
// List all folders, optionally filtered by parent
|
||||
fastify.get('/api/global-folders', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const parentId = (request.query as { parentId?: string | null }).parentId;
|
||||
if (parentId === undefined || parentId === '' || parentId === 'null') {
|
||||
return db.listGlobalFolders(parentId === 'null' ? null : undefined);
|
||||
}
|
||||
const idErr = validateIdParam(parentId, 'parentId');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
return db.listGlobalFolders(parentId);
|
||||
});
|
||||
|
||||
// Get a single folder
|
||||
fastify.get('/api/global-folders/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const folder = db.getGlobalFolder(id);
|
||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
||||
return folder;
|
||||
});
|
||||
|
||||
// Create a new folder
|
||||
fastify.post('/api/global-folders', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const body = request.body as { name?: string; parent_id?: string | null };
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
if (body.parent_id !== undefined && body.parent_id !== null) {
|
||||
const pidErr = validateIdParam(body.parent_id, 'parent_id');
|
||||
if (pidErr) return reply.code(400).send({ error: pidErr });
|
||||
}
|
||||
return reply.code(201).send(db.createGlobalFolder({ name: body.name, parent_id: body.parent_id ?? null }));
|
||||
});
|
||||
|
||||
// Rename / move a folder
|
||||
fastify.patch('/api/global-folders/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const body = request.body as { name?: string; parent_id?: string | null };
|
||||
if (body.name !== undefined) {
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
}
|
||||
if (body.parent_id !== undefined && body.parent_id !== null) {
|
||||
const pidErr = validateIdParam(body.parent_id, 'parent_id');
|
||||
if (pidErr) return reply.code(400).send({ error: pidErr });
|
||||
// Prevent setting parent to self
|
||||
if (body.parent_id === id) return reply.code(400).send({ error: 'Cannot set parent to self' });
|
||||
}
|
||||
const updated = db.updateGlobalFolder(id, { name: body.name, parent_id: body.parent_id });
|
||||
if (!updated) return reply.code(404).send({ error: 'Folder not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a folder (cascades to children, blocks get folder_id = NULL)
|
||||
fastify.delete('/api/global-folders/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const ok = db.deleteGlobalFolder(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Folder not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// ─── Global Blocks ────────────────────────────────────
|
||||
|
||||
// List all global blocks, optionally filtered by folder
|
||||
fastify.get('/api/global-blocks', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const folderId = (request.query as { folderId?: string | null }).folderId;
|
||||
if (folderId === undefined || folderId === '' || folderId === 'null') {
|
||||
return db.listGlobalBlocks(folderId === 'null' ? null : undefined);
|
||||
}
|
||||
const idErr = validateIdParam(folderId, 'folderId');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
return db.listGlobalBlocks(folderId);
|
||||
});
|
||||
|
||||
// Get a single global block
|
||||
fastify.get('/api/global-blocks/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const block = db.getGlobalBlock(id);
|
||||
if (!block) return reply.code(404).send({ error: 'Global block not found' });
|
||||
return block;
|
||||
});
|
||||
|
||||
// Create a new global block
|
||||
fastify.post('/api/global-blocks', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
if (body.folder_id !== undefined && body.folder_id !== null) {
|
||||
const fidErr = validateIdParam(body.folder_id, 'folder_id');
|
||||
if (fidErr) return reply.code(400).send({ error: fidErr });
|
||||
}
|
||||
if (body.block_data !== undefined && typeof body.block_data !== 'string') {
|
||||
return reply.code(400).send({ error: 'block_data must be a JSON string' });
|
||||
}
|
||||
if (body.svg_data !== undefined && typeof body.svg_data !== 'string') {
|
||||
return reply.code(400).send({ error: 'svg_data must be a string' });
|
||||
}
|
||||
return reply.code(201).send(db.createGlobalBlock({
|
||||
name: body.name,
|
||||
folder_id: body.folder_id ?? null,
|
||||
block_data: body.block_data ?? '{}',
|
||||
svg_data: body.svg_data ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
// Update a global block (rename, move, update data)
|
||||
fastify.patch('/api/global-blocks/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
|
||||
if (body.name !== undefined) {
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
}
|
||||
if (body.folder_id !== undefined && body.folder_id !== null) {
|
||||
const fidErr = validateIdParam(body.folder_id, 'folder_id');
|
||||
if (fidErr) return reply.code(400).send({ error: fidErr });
|
||||
}
|
||||
if (body.block_data !== undefined && typeof body.block_data !== 'string') {
|
||||
return reply.code(400).send({ error: 'block_data must be a JSON string' });
|
||||
}
|
||||
if (body.svg_data !== undefined && typeof body.svg_data !== 'string') {
|
||||
return reply.code(400).send({ error: 'svg_data must be a string' });
|
||||
}
|
||||
const updated = db.updateGlobalBlock(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Global block not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a global block
|
||||
fastify.delete('/api/global-blocks/:id', async (request, reply) => {
|
||||
if (!requireAuth(request, reply, authService)) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const ok = db.deleteGlobalBlock(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Global block not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export async function createServer(opts: ServerOptions) {
|
||||
const { registerAIRoutes } = await import('./routes/ai.js');
|
||||
const { registerNotificationRoutes } = await import('./routes/notifications.js');
|
||||
const { registerShareRoutes } = await import('./routes/shares.js');
|
||||
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
|
||||
|
||||
const authService = new AuthService(opts.db);
|
||||
|
||||
@@ -100,6 +101,7 @@ export async function createServer(opts: ServerOptions) {
|
||||
registerAIRoutes(fastify, authService);
|
||||
registerNotificationRoutes(fastify, opts.db, authService);
|
||||
registerShareRoutes(fastify, opts.db, authService);
|
||||
registerGlobalBlockRoutes(fastify, opts.db, authService);
|
||||
|
||||
// Yjs collaboration WebSocket
|
||||
registerYjsWebSocket(fastify, authService);
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user