feat: dashboard 3-column layout — project folders treeview, project cards with open button, info panel
This commit is contained in:
@@ -7,10 +7,19 @@ export interface DBProject {
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
folder_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBProjectFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DBDrawing {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -129,11 +138,19 @@ export interface DatabaseInterface {
|
||||
listUsers(): DBUser[];
|
||||
|
||||
// Projects
|
||||
listProjects(ownerId?: string): DBProject[];
|
||||
listProjects(ownerId?: string, folderId?: string | null): DBProject[];
|
||||
getProject(id: string): DBProject | null;
|
||||
createProject(data: Partial<DBProject>): DBProject;
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
|
||||
deleteProject(id: string): boolean;
|
||||
moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null;
|
||||
|
||||
// Project Folders
|
||||
listProjectFolders(ownerId: string): DBProjectFolder[];
|
||||
getProjectFolder(id: string): DBProjectFolder | null;
|
||||
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder;
|
||||
updateProjectFolder(id: string, data: Partial<DBProjectFolder>): DBProjectFolder | null;
|
||||
deleteProjectFolder(id: string): boolean;
|
||||
|
||||
// Drawings
|
||||
listDrawings(projectId: string): DBDrawing[];
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
||||
DBNotification, DBProjectShare,
|
||||
DBGlobalBlockFolder, DBGlobalBlock,
|
||||
DBProjectFolder,
|
||||
} from './DatabaseInterface.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -28,6 +29,11 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
const schemaPath = join(__dirname, 'schema.sql');
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
this.db.exec(schema);
|
||||
// Add folder_id column to projects table for existing databases
|
||||
const columns = this.db.prepare("PRAGMA table_info('projects')").all() as { name: string }[];
|
||||
if (!columns.some(col => col.name === 'folder_id')) {
|
||||
this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL');
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -74,10 +80,22 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
}
|
||||
|
||||
// ─── Projects ──────────────────────────────────────
|
||||
listProjects(ownerId?: string): DBProject[] {
|
||||
listProjects(ownerId?: string, folderId?: string | null): DBProject[] {
|
||||
if (ownerId && folderId !== undefined) {
|
||||
if (folderId === null) {
|
||||
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC').all(ownerId) as DBProject[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC').all(ownerId, folderId) as DBProject[];
|
||||
}
|
||||
if (ownerId) {
|
||||
return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? ORDER BY updated_at DESC').all(ownerId) as DBProject[];
|
||||
}
|
||||
if (folderId !== undefined) {
|
||||
if (folderId === null) {
|
||||
return this.db.prepare('SELECT * FROM projects WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBProject[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM projects WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBProject[];
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
|
||||
}
|
||||
|
||||
@@ -88,16 +106,17 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
createProject(data: Partial<DBProject>): DBProject {
|
||||
const id = data.id ?? `proj-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)',
|
||||
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default');
|
||||
'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null);
|
||||
return this.getProject(id)!;
|
||||
}
|
||||
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
const vals: unknown[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
|
||||
if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
@@ -109,6 +128,45 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null {
|
||||
this.db.prepare('UPDATE projects SET folder_id = ?, updated_at = datetime(\'now\') WHERE id = ?').run(folderId, projectId);
|
||||
return this.getProject(projectId);
|
||||
}
|
||||
|
||||
// ─── Project Folders ─────────────────────────────────
|
||||
listProjectFolders(ownerId: string): DBProjectFolder[] {
|
||||
return this.db.prepare('SELECT * FROM project_folders WHERE owner_id = ? ORDER BY name').all(ownerId) as DBProjectFolder[];
|
||||
}
|
||||
|
||||
getProjectFolder(id: string): DBProjectFolder | null {
|
||||
return (this.db.prepare('SELECT * FROM project_folders WHERE id = ?').get(id) as DBProjectFolder) ?? null;
|
||||
}
|
||||
|
||||
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
|
||||
const id = data.id ?? `pfolder-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)',
|
||||
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default');
|
||||
return this.getProjectFolder(id)!;
|
||||
}
|
||||
|
||||
updateProjectFolder(id: string, data: Partial<DBProjectFolder>): DBProjectFolder | 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 project_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getProjectFolder(id);
|
||||
}
|
||||
|
||||
deleteProjectFolder(id: string): boolean {
|
||||
// Move projects inside this folder to root (folder_id = NULL) before deleting
|
||||
this.db.prepare('UPDATE projects SET folder_id = NULL WHERE folder_id = ?').run(id);
|
||||
return this.db.prepare('DELETE FROM project_folders WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Drawings ──────────────────────────────────────
|
||||
listDrawings(projectId: string): DBDrawing[] {
|
||||
return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[];
|
||||
|
||||
@@ -99,6 +99,25 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Project Folders ───────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS project_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (parent_id) REFERENCES project_folders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_folders_parent ON project_folders(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_folders_owner ON project_folders(owner_id);
|
||||
|
||||
-- ─── Projects: add folder_id column ─────────────────────
|
||||
-- NOTE: ALTER TABLE is idempotent-safe via try/catch in adapter; schema.sql
|
||||
-- is only exec'd on fresh databases. For existing DBs, the adapter runs
|
||||
-- the ALTER in init().
|
||||
|
||||
-- ─── Indexes ────────────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Project Folders Routes – CRUD for project folders + move project to folder
|
||||
*/
|
||||
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 registerProjectFolderRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// ─── Project Folders ──────────────────────────────────
|
||||
|
||||
// List all folders for the authenticated user
|
||||
fastify.get('/api/project-folders', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) return;
|
||||
return db.listProjectFolders(user.id);
|
||||
});
|
||||
|
||||
// Get a single folder
|
||||
fastify.get('/api/project-folders/:id', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) 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.getProjectFolder(id);
|
||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
return folder;
|
||||
});
|
||||
|
||||
// Create a new folder
|
||||
fastify.post('/api/project-folders', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) 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 });
|
||||
// Verify parent exists and belongs to user
|
||||
const parent = db.getProjectFolder(body.parent_id);
|
||||
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
|
||||
if (parent.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
return reply.code(201).send(db.createProjectFolder({
|
||||
name: body.name,
|
||||
parent_id: body.parent_id ?? null,
|
||||
owner_id: user.id,
|
||||
}));
|
||||
});
|
||||
|
||||
// Rename a folder
|
||||
fastify.put('/api/project-folders/:id', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) 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.getProjectFolder(id);
|
||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const body = request.body as { name?: string };
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
const updated = db.updateProjectFolder(id, { name: body.name });
|
||||
if (!updated) return reply.code(404).send({ error: 'Folder not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a folder (projects inside move to root)
|
||||
fastify.delete('/api/project-folders/:id', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) 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.getProjectFolder(id);
|
||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const ok = db.deleteProjectFolder(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Folder not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Move project to folder
|
||||
fastify.put('/api/projects/:id/folder', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) return;
|
||||
const { id } = request.params as { id: string };
|
||||
const idErr = validateIdParam(id, 'id');
|
||||
if (idErr) return reply.code(400).send({ error: idErr });
|
||||
const project = db.getProject(id);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const body = request.body as { folder_id?: string | null };
|
||||
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 });
|
||||
// Verify folder exists and belongs to user
|
||||
const folder = db.getProjectFolder(body.folder_id);
|
||||
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
|
||||
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
const updated = db.moveProjectToFolder(id, body.folder_id ?? null);
|
||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,17 @@ import type { AuthService } from '../auth/AuthService.js';
|
||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||
|
||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// List projects owned by the authenticated user
|
||||
// List projects owned by the authenticated user, optionally filtered by folder
|
||||
fastify.get('/api/projects', async (request, reply) => {
|
||||
const user = requireAuth(request, reply, authService);
|
||||
if (!user) return;
|
||||
return db.listProjects(user.id);
|
||||
const folderId = (request.query as { folderId?: string | null }).folderId;
|
||||
if (folderId === undefined || folderId === '' || folderId === 'null') {
|
||||
return db.listProjects(user.id, folderId === 'null' ? null : undefined);
|
||||
}
|
||||
const fidErr = validateIdParam(folderId, 'folderId');
|
||||
if (fidErr) return reply.code(400).send({ error: fidErr });
|
||||
return db.listProjects(user.id, folderId);
|
||||
});
|
||||
|
||||
// Get single project
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function createServer(opts: ServerOptions) {
|
||||
const { registerNotificationRoutes } = await import('./routes/notifications.js');
|
||||
const { registerShareRoutes } = await import('./routes/shares.js');
|
||||
const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js');
|
||||
const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js');
|
||||
|
||||
const authService = new AuthService(opts.db);
|
||||
|
||||
@@ -102,6 +103,7 @@ export async function createServer(opts: ServerOptions) {
|
||||
registerNotificationRoutes(fastify, opts.db, authService);
|
||||
registerShareRoutes(fastify, opts.db, authService);
|
||||
registerGlobalBlockRoutes(fastify, opts.db, authService);
|
||||
registerProjectFolderRoutes(fastify, opts.db, authService);
|
||||
|
||||
// Yjs collaboration WebSocket
|
||||
registerYjsWebSocket(fastify, authService);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Project Folders API Tests - CRUD for project folders + move project to folder
|
||||
*/
|
||||
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('Project Folders API', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let authToken: string;
|
||||
let createdFolderId: string;
|
||||
let childFolderId: string;
|
||||
let createdProjectId: 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: 'folder-test@example.com', password: 'Password123!', name: 'Folder Test' },
|
||||
});
|
||||
const regBody = JSON.parse(regRes.body);
|
||||
authToken = regBody.session.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('should list empty folders initially', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should create a folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { name: 'Test Folder' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('Test Folder');
|
||||
expect(body.parent_id).toBeNull();
|
||||
expect(body.id).toBeDefined();
|
||||
createdFolderId = body.id;
|
||||
});
|
||||
|
||||
it('should create a child folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { name: 'Child Folder', parent_id: createdFolderId },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('Child Folder');
|
||||
expect(body.parent_id).toBe(createdFolderId);
|
||||
childFolderId = body.id;
|
||||
});
|
||||
|
||||
it('should list folders including the created ones', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.length).toBe(2);
|
||||
expect(body.some((f: { id: string }) => f.id === createdFolderId)).toBe(true);
|
||||
expect(body.some((f: { id: string }) => f.id === childFolderId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should get a single folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/project-folders/${createdFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.id).toBe(createdFolderId);
|
||||
expect(body.name).toBe('Test Folder');
|
||||
});
|
||||
|
||||
it('should rename a folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/project-folders/${createdFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { name: 'Renamed Folder' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('Renamed Folder');
|
||||
});
|
||||
|
||||
it('should reject folder creation without name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject rename without name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/project-folders/${createdFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject operations on non-existent folder', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/project-folders/nonexistent-folder',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
|
||||
const delRes = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/project-folders/nonexistent-folder',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(delRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/project-folders',
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
// ─── Project + Folder integration ────────────────────
|
||||
|
||||
it('should create a project with folder_id', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { name: 'Folder Project', folder_id: createdFolderId },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('Folder Project');
|
||||
expect(body.folder_id).toBe(createdFolderId);
|
||||
createdProjectId = body.id;
|
||||
});
|
||||
|
||||
it('should list projects filtered by folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects?folderId=${createdFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.length).toBe(1);
|
||||
expect(body[0].id).toBe(createdProjectId);
|
||||
});
|
||||
|
||||
it('should list unfoldered projects with folderId=null', async () => {
|
||||
// First create a project without folder
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { name: 'No Folder Project' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/projects?folderId=null',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.every((p: { folder_id: string | null }) => p.folder_id === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should move project to a different folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/projects/${createdProjectId}/folder`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { folder_id: childFolderId },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.folder_id).toBe(childFolderId);
|
||||
});
|
||||
|
||||
it('should move project to root (folder_id = null)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/projects/${createdProjectId}/folder`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { folder_id: null },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.folder_id).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject moving project to non-existent folder', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/projects/${createdProjectId}/folder`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { folder_id: 'nonexistent-folder' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should delete folder and move projects to root', async () => {
|
||||
// Move project back to folder first
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/projects/${createdProjectId}/folder`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { folder_id: childFolderId },
|
||||
});
|
||||
|
||||
// Delete the child folder
|
||||
const delRes = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/project-folders/${childFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(delRes.statusCode).toBe(204);
|
||||
|
||||
// Verify project is now in root
|
||||
const projRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(projRes.statusCode).toBe(200);
|
||||
const projBody = JSON.parse(projRes.body);
|
||||
expect(projBody.folder_id).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete remaining folders', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/project-folders/${createdFolderId}`,
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
|
||||
// Verify no folders left
|
||||
const listRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/project-folders',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
const listBody = JSON.parse(listRes.body);
|
||||
expect(listBody.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user