fix: replace hardcoded localhost:3001 with relative API URL + add canvas mock and integration test

- Fix frontend Network error: API_BASE now defaults to empty string (same-origin)
- Fix 3 files: api.ts, AuthContext.tsx, Dashboard.tsx
- Add Canvas 2D context mock in tests/setup.ts for jsdom
- Fix -0 vs +0 in ZoomPanController.getViewport()
- Add IntegrationWorkflow.test.ts (35 tests, full CAD workflow)
This commit is contained in:
2026-06-26 17:48:24 +02:00
parent 89b91a1050
commit 1bcfaffdd4
22 changed files with 4867 additions and 7 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* AuthService Unit Tests Register / Login / ChangePassword / Session lifecycle
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { AuthService } from '../src/auth/AuthService.js';
describe('AuthService', () => {
let db: SqliteAdapter;
let auth: AuthService;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
auth = new AuthService(db);
});
afterAll(() => {
db.close();
});
// ─── Register ───────────────────────────────────────
describe('register()', () => {
it('should register a new user successfully', async () => {
const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.user.name).toBe('Register User');
expect(result.user.password_hash).toBeUndefined();
expect(result.session).toBeDefined();
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on duplicate email', async () => {
await expect(
auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'),
).rejects.toThrow('Email already registered');
});
it('should default role to planer when not specified', async () => {
const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User');
expect(result.user.role).toBe('planer');
});
});
// ─── Login ─────────────────────────────────────────
describe('login()', () => {
it('should login with correct credentials', async () => {
const result = await auth.login('register@example.com', 'Password123!');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on wrong password', async () => {
await expect(
auth.login('register@example.com', 'WrongPassword!'),
).rejects.toThrow('Invalid credentials');
});
it('should throw on non-existent email', async () => {
await expect(
auth.login('nobody@example.com', 'SomePassword!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Change Password ───────────────────────────────
describe('changePassword()', () => {
it('should change password with correct old password', async () => {
const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin');
const userId = result.user.id;
await auth.changePassword(userId, 'OldPass123!', 'NewPass456!');
// Should now be able to login with new password
const loginResult = await auth.login('changepw@example.com', 'NewPass456!');
expect(loginResult.user.id).toBe(userId);
});
it('should throw on wrong old password', async () => {
const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer');
await expect(
auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'),
).rejects.toThrow('Invalid current password');
});
it('should throw on non-existent user', async () => {
await expect(
auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'),
).rejects.toThrow('User not found');
});
it('should reject old password after change', async () => {
const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer');
await auth.changePassword(result.user.id, 'Original123!', 'Changed456!');
await expect(
auth.login('changepw3@example.com', 'Original123!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Session Management ────────────────────────────
describe('Session lifecycle', () => {
it('should create and validate a session', async () => {
const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer');
const token = regResult.session.token;
const session = auth.validateSession(token);
expect(session).not.toBeNull();
expect(session!.token).toBe(token);
expect(session!.userId).toBe(regResult.user.id);
});
it('should return null for invalid session token', () => {
const session = auth.validateSession('invalid-token-xyz');
expect(session).toBeNull();
});
it('should destroy a session and invalidate it', async () => {
const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer');
const token = regResult.session.token;
// Session should be valid
expect(auth.validateSession(token)).not.toBeNull();
// Destroy it
auth.destroySession(token);
// Session should now be invalid
expect(auth.validateSession(token)).toBeNull();
});
it('should handle destroying a non-existent session gracefully', () => {
// Should not throw
auth.destroySession('non-existent-token');
});
});
// ─── getUserFromSession ─────────────────────────────
describe('getUserFromSession()', () => {
it('should return user from valid session', async () => {
const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer');
const token = regResult.session.token;
const user = auth.getUserFromSession(token);
expect(user).not.toBeNull();
expect(user!.email).toBe('getuser@example.com');
expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash
});
it('should return null for invalid session token', () => {
const user = auth.getUserFromSession('invalid-token-xyz');
expect(user).toBeNull();
});
it('should return null after session is destroyed', async () => {
const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer');
const token = regResult.session.token;
auth.destroySession(token);
const user = auth.getUserFromSession(token);
expect(user).toBeNull();
});
});
// ─── createSession (standalone) ─────────────────────
describe('createSession()', () => {
it('should create a session with a unique token', async () => {
const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer');
const session1 = auth.createSession(regResult.user.id);
const session2 = auth.createSession(regResult.user.id);
expect(session1.token).not.toBe(session2.token);
expect(session1.userId).toBe(regResult.user.id);
expect(session2.userId).toBe(regResult.user.id);
expect(session1.expiresAt).toBeGreaterThan(Date.now());
expect(session2.expiresAt).toBeGreaterThan(Date.now());
});
});
});
+383
View File
@@ -0,0 +1,383 @@
/**
* SqliteAdapter Unit Tests init, CRUD for all entities, close
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
let idCounter = 0;
function uniqueId(prefix: string): string {
idCounter++;
return `${prefix}-${Date.now()}-${idCounter}`;
}
describe('SqliteAdapter', () => {
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
});
afterAll(() => {
db.close();
});
// ─── Init & Close ───────────────────────────────────
describe('init() and close()', () => {
it('should initialize the database without errors', async () => {
const testDb = new SqliteAdapter(':memory:');
await testDb.init();
const users = testDb.listUsers();
expect(Array.isArray(users)).toBe(true);
testDb.close();
});
it('should seed a default user on init', async () => {
const defaultUser = db.getUser('user-default');
expect(defaultUser).not.toBeNull();
expect(defaultUser!.email).toBe('default@webcad.local');
expect(defaultUser!.role).toBe('admin');
});
});
// ─── Users CRUD ─────────────────────────────────────
describe('Users CRUD', () => {
it('should create and get a user', () => {
const uid = uniqueId('user');
const user = db.createUser({
id: uid,
email: `${uid}@example.com`,
password_hash: 'hash123',
name: 'CRUD User',
role: 'planer',
});
expect(user.id).toBe(uid);
expect(user.email).toBe(`${uid}@example.com`);
const fetched = db.getUser(user.id);
expect(fetched).not.toBeNull();
expect(fetched!.email).toBe(`${uid}@example.com`);
});
it('should get user by email', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' });
const user = db.getUserByEmail(`${uid}@example.com`);
expect(user).not.toBeNull();
expect(user!.name).toBe('Email User');
});
it('should return null for non-existent user', () => {
expect(db.getUser('non-existent-id')).toBeNull();
expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull();
});
it('should update a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' });
const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Name');
expect(updated!.role).toBe('admin');
});
it('should list users', () => {
const users = db.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
});
it('should delete a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' });
const ok = db.deleteUser(uid);
expect(ok).toBe(true);
expect(db.getUser(uid)).toBeNull();
});
it('should return false when deleting non-existent user', () => {
expect(db.deleteUser('non-existent-id')).toBe(false);
});
});
// ─── Projects CRUD ──────────────────────────────────
describe('Projects CRUD', () => {
it('should create and get a project', () => {
const pid = uniqueId('proj');
const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' });
expect(project.id).toBe(pid);
expect(project.name).toBe('Test Project');
const fetched = db.getProject(pid);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Test Project');
});
it('should return null for non-existent project', () => {
expect(db.getProject('non-existent-id')).toBeNull();
});
it('should update a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Update Project' });
const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Project');
expect(updated!.description).toBe('New desc');
});
it('should list projects', () => {
const projects = db.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(1);
});
it('should delete a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Delete Project' });
const ok = db.deleteProject(pid);
expect(ok).toBe(true);
expect(db.getProject(pid)).toBeNull();
});
it('should cascade delete drawings when project is deleted', () => {
const pid = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: pid, name: 'Cascade Project' });
db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' });
db.deleteProject(pid);
expect(db.getDrawing(did)).toBeNull();
});
});
// ─── Drawings CRUD ──────────────────────────────────
describe('Drawings CRUD', () => {
let projectId: string;
it('should create and get a drawing', () => {
projectId = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: projectId, name: 'Drawing Project' });
const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' });
expect(drawing.id).toBe(did);
expect(drawing.name).toBe('Floor 1');
expect(drawing.project_id).toBe(projectId);
const fetched = db.getDrawing(did);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Floor 1');
});
it('should list drawings by project', () => {
const did1 = uniqueId('draw');
const did2 = uniqueId('draw');
db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' });
db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' });
const drawings = db.listDrawings(projectId);
expect(drawings.length).toBeGreaterThanOrEqual(3);
});
it('should update a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' });
const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Drawing');
expect(updated!.data_json).toBe('{"x":1}');
});
it('should delete a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' });
const ok = db.deleteDrawing(did);
expect(ok).toBe(true);
expect(db.getDrawing(did)).toBeNull();
});
it('should cascade delete layers when drawing is deleted', () => {
const did = uniqueId('draw');
const lid = uniqueId('layer');
db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' });
db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' });
db.deleteDrawing(did);
const layers = db.listLayers(did);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Layers CRUD ────────────────────────────────────
describe('Layers CRUD', () => {
let drawingId: string;
it('should create and get a layer', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const lid = uniqueId('layer');
db.createProject({ id: pid, name: 'Layer Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' });
const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' });
expect(layer.id).toBe(lid);
expect(layer.name).toBe('Layer 1');
expect(layer.color).toBe('#ff0000');
expect(layer.visible).toBe(1);
expect(layer.locked).toBe(0);
});
it('should list layers by drawing', () => {
const lid2 = uniqueId('layer');
db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' });
const layers = db.listLayers(drawingId);
expect(layers.length).toBeGreaterThanOrEqual(2);
});
it('should update a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' });
const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Layer');
expect(updated!.visible).toBe(0);
expect(updated!.locked).toBe(1);
});
it('should delete a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' });
const ok = db.deleteLayer(lid);
expect(ok).toBe(true);
const layers = db.listLayers(drawingId);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Elements CRUD ──────────────────────────────────
describe('Elements CRUD', () => {
let drawingId: string;
let layerId: string;
it('should create and get an element', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
layerId = uniqueId('layer');
const eid = uniqueId('elem');
db.createProject({ id: pid, name: 'Element Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' });
db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' });
const element = db.createElement({
id: eid,
drawing_id: drawingId,
layer_id: layerId,
type: 'rect',
x: 10,
y: 20,
width: 100,
height: 50,
});
expect(element.id).toBe(eid);
expect(element.type).toBe('rect');
expect(element.x).toBe(10);
expect(element.y).toBe(20);
});
it('should list elements by drawing', () => {
const eid2 = uniqueId('elem');
db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' });
const elements = db.listElements(drawingId);
expect(elements.length).toBeGreaterThanOrEqual(2);
});
it('should update an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' });
const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 });
expect(updated).not.toBeNull();
expect(updated!.x).toBe(500);
expect(updated!.y).toBe(600);
expect(updated!.width).toBe(200);
});
it('should delete an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' });
const ok = db.deleteElement(eid);
expect(ok).toBe(true);
const elements = db.listElements(drawingId);
expect(elements.find((e) => e.id === eid)).toBeUndefined();
});
});
// ─── Blocks CRUD ────────────────────────────────────
describe('Blocks CRUD', () => {
let drawingId: string;
it('should create and get a block', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const bid = uniqueId('block');
db.createProject({ id: pid, name: 'Block Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' });
const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' });
expect(block.id).toBe(bid);
expect(block.name).toBe('Table Block');
expect(block.category).toBe('Mobiliar');
expect(block.elements_json).toBe('[]');
});
it('should list blocks by drawing', () => {
const bid2 = uniqueId('block');
db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' });
const blocks = db.listBlocks(drawingId);
expect(blocks.length).toBeGreaterThanOrEqual(2);
});
it('should update a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' });
const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Block');
expect(updated!.category).toBe('Bühne');
});
it('should delete a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' });
const ok = db.deleteBlock(bid);
expect(ok).toBe(true);
const blocks = db.listBlocks(drawingId);
expect(blocks.find((b) => b.id === bid)).toBeUndefined();
});
});
// ─── Settings CRUD ──────────────────────────────────
describe('Settings CRUD', () => {
it('should set and get a setting', () => {
db.setSetting('test-key', 'test-value');
const setting = db.getSetting('test-key');
expect(setting).not.toBeNull();
expect(setting!.key).toBe('test-key');
expect(setting!.value).toBe('test-value');
expect(setting!.updated_at).toBeDefined();
});
it('should return null for non-existent setting', () => {
expect(db.getSetting('non-existent-key')).toBeNull();
});
it('should upsert (update existing setting)', () => {
db.setSetting('upsert-key', 'initial');
db.setSetting('upsert-key', 'updated');
const setting = db.getSetting('upsert-key');
expect(setting).not.toBeNull();
expect(setting!.value).toBe('updated');
});
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* AI API Tests Auth guard, validation, and service availability checks
* Does NOT test actual OpenRouter API calls.
*/
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('AI API', () => {
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();
// Register a user to get an auth token
const registerRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'ai-test@example.com',
password: 'TestPass123!',
name: 'AI Test User',
role: 'planer',
},
});
const body = JSON.parse(registerRes.body);
authToken = body.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Guard ─────────────────────────────────────
describe('POST /api/ai/chat Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Authentication required');
});
it('should return 401 with invalid token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'Bearer invalid-token-xxx' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Invalid or expired session');
});
it('should return 401 with malformed authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'NotBearer sometoken' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Validation ─────────────────────────────────────
describe('POST /api/ai/chat Validation', () => {
it('should return 400 when messages array is missing', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages array is empty', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [] },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages is not an array', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: 'not an array' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Service Availability ───────────────────────────
describe('POST /api/ai/chat Service availability', () => {
it('should return 503 when API_KEY_OPENROUTER is not set', async () => {
// Ensure env var is not set for this test
const originalValue = process.env.API_KEY_OPENROUTER;
delete process.env.API_KEY_OPENROUTER;
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(503);
const body = JSON.parse(response.body);
expect(body.error).toContain('AI service not configured');
// Restore original value if it existed
if (originalValue !== undefined) {
process.env.API_KEY_OPENROUTER = originalValue;
}
});
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Blocks API Tests CRUD (List / Create / Update / Delete)
*/
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('Blocks API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdBlockId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Blocks Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Blocks Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/blocks', () => {
it('should create a block with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'Standard Table', category: 'Mobiliar', description: 'A standard round table' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Standard Table');
expect(body.category).toBe('Mobiliar');
expect(body.description).toBe('A standard round table');
expect(body.drawing_id).toBe(drawingId);
createdBlockId = body.id;
});
it('should create a block with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'Minimal Block' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Minimal Block');
expect(body.category).toBe('Allgemein');
expect(body.description).toBeNull();
expect(body.elements_json).toBe('[]');
});
it('should reject block without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { category: 'Test' },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Name is required');
});
it('should reject block with empty name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: '' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/blocks', () => {
it('should list blocks for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no blocks', async () => {
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Blocks Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/blocks`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/blocks/:id', () => {
it('should update block name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { name: 'Updated Block Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Block Name');
});
it('should update block category and description', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { category: 'Bühne', description: 'Stage equipment' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.category).toBe('Bühne');
expect(body.description).toBe('Stage equipment');
});
it('should update block elements_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { elements_json: '[{"type":"rect"}]' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.elements_json).toBe('[{"type":"rect"}]');
});
it('should return 404 for non-existent block update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/blocks/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/blocks/:id', () => {
it('should delete a block', async () => {
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'To Be Deleted' },
});
const blockId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/blocks/${blockId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
const blocks = JSON.parse(listRes.body);
expect(blocks.find((b: any) => b.id === blockId)).toBeUndefined();
});
it('should return 404 for non-existent block delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/blocks/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Drawings API Tests CRUD (List / Get / Create / Update / Delete)
*/
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('Drawings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let projectId: string;
let createdDrawingId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a project first (drawings belong to projects)
const projectRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Drawings Test Project' },
});
projectId = JSON.parse(projectRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects/:projectId/drawings', () => {
it('should create a drawing with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Ground Floor' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Ground Floor');
expect(body.project_id).toBe(projectId);
createdDrawingId = body.id;
});
it('should create a drawing with default name', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Unbenannt');
expect(body.project_id).toBe(projectId);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/projects/:projectId/drawings', () => {
it('should list drawings for a project', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}/drawings`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for project with no drawings', async () => {
// Create a new empty project
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Project' },
});
const emptyProjId = JSON.parse(projRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/projects/${emptyProjId}/drawings`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/drawings/:id', () => {
it('should get a single drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${createdDrawingId}`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdDrawingId);
expect(body.name).toBe('Ground Floor');
});
it('should return 404 for non-existent drawing', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/drawings/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/drawings/:id', () => {
it('should update drawing name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
payload: { name: 'First Floor' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('First Floor');
});
it('should update drawing data_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
payload: { data_json: '{"layers":[]}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.data_json).toBe('{"layers":[]}');
});
it('should return 404 for non-existent drawing update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/drawings/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/drawings/:id', () => {
it('should delete a drawing', async () => {
// Create a drawing to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'To Be Deleted' },
});
const drawingId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/drawings/${drawingId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}`,
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent drawing delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/drawings/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Elements API Tests CRUD (List / Create / Update / Delete)
*/
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('Elements API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let createdElementId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing → layer hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Elements Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Elements Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
const layerRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'Elements Layer' },
});
layerId = JSON.parse(layerRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/elements', () => {
it('should create an element with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'rect', x: 10, y: 20, width: 100, height: 50 },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.type).toBe('rect');
expect(body.x).toBe(10);
expect(body.y).toBe(20);
expect(body.width).toBe(100);
expect(body.height).toBe(50);
expect(body.drawing_id).toBe(drawingId);
expect(body.layer_id).toBe(layerId);
createdElementId = body.id;
});
it('should create an element with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'circle' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.type).toBe('circle');
expect(body.x).toBe(0);
expect(body.y).toBe(0);
expect(body.width).toBe(0);
expect(body.height).toBe(0);
expect(body.properties_json).toBe('{}');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/elements', () => {
it('should list elements for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no elements', async () => {
// Create a new drawing with no elements
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Elements Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/elements`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/elements/:id', () => {
it('should update element position', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { x: 100, y: 200 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.x).toBe(100);
expect(body.y).toBe(200);
});
it('should update element dimensions', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { width: 300, height: 150 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.width).toBe(300);
expect(body.height).toBe(150);
});
it('should update element properties_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { properties_json: '{"color":"red"}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.properties_json).toBe('{"color":"red"}');
});
it('should return 404 for non-existent element update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/elements/non-existent-id',
payload: { x: 0 },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/elements/:id', () => {
it('should delete an element', async () => {
// Create an element to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'line' },
});
const elemId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/elements/${elemId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
const elements = JSON.parse(listRes.body);
expect(elements.find((e: any) => e.id === elemId)).toBeUndefined();
});
it('should return 404 for non-existent element delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/elements/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Layers API Tests CRUD (List / Create / Update / Delete)
*/
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('Layers API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdLayerId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Layers Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Layers Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/layers', () => {
it('should create a layer with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'Background', color: '#ff0000' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Background');
expect(body.color).toBe('#ff0000');
expect(body.drawing_id).toBe(drawingId);
createdLayerId = body.id;
});
it('should create a layer with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Layer');
expect(body.visible).toBe(1);
expect(body.locked).toBe(0);
expect(body.color).toBe('#ffffff');
expect(body.line_type).toBe('solid');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/layers', () => {
it('should list layers for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no layers', async () => {
// Create a new drawing with no layers
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Layers Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/layers`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/layers/:id', () => {
it('should update layer name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { name: 'Updated Layer Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Layer Name');
});
it('should update layer visibility', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { visible: 0, locked: 1 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.visible).toBe(0);
expect(body.locked).toBe(1);
});
it('should update layer color and line_type', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { color: '#00ff00', line_type: 'dashed' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.color).toBe('#00ff00');
expect(body.line_type).toBe('dashed');
});
it('should return 404 for non-existent layer update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/layers/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/layers/:id', () => {
it('should delete a layer', async () => {
// Create a layer to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'To Be Deleted' },
});
const layerId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/layers/${layerId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
const layers = JSON.parse(listRes.body);
expect(layers.find((l: any) => l.id === layerId)).toBeUndefined();
});
it('should return 404 for non-existent layer delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/layers/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Settings API Tests Key/Value settings (Get / Put / Upsert)
*/
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('Settings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Get (missing key → 404) ─────────────────────────
describe('GET /api/settings/:key', () => {
it('should return 404 for non-existent setting', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/non-existent-key',
});
expect(response.statusCode).toBe(404);
const body = JSON.parse(response.body);
expect(body.error).toContain('Setting not found');
});
it('should return a setting that was previously set', async () => {
// First set a value
await app.inject({
method: 'PUT',
url: '/api/settings/test-key',
payload: { value: 'test-value' },
});
const response = await app.inject({
method: 'GET',
url: '/api/settings/test-key',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('test-key');
expect(body.value).toBe('test-value');
expect(body.updated_at).toBeDefined();
});
});
// ─── Put (upsert) ───────────────────────────────────
describe('PUT /api/settings/:key', () => {
it('should create a new setting', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/new-setting',
payload: { value: 'new-value' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('new-setting');
expect(body.value).toBe('new-value');
expect(body.updated_at).toBeDefined();
});
it('should update an existing setting (upsert)', async () => {
// Create initial
await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
payload: { value: 'initial' },
});
// Update it
const response = await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
payload: { value: 'updated' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('upsert-key');
expect(body.value).toBe('updated');
// Verify via GET
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/upsert-key',
});
const getBody = JSON.parse(getRes.body);
expect(getBody.value).toBe('updated');
});
it('should reject setting without value', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/no-value-key',
payload: {},
});
const body = JSON.parse(response.body);
expect(body.error).toContain('Value is required');
});
it('should handle complex JSON values', async () => {
const complexValue = JSON.stringify({ nested: { object: true }, array: [1, 2, 3] });
const response = await app.inject({
method: 'PUT',
url: '/api/settings/complex-config',
payload: { value: complexValue },
});
expect(response.statusCode).toBe(200);
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/complex-config',
});
const body = JSON.parse(getRes.body);
expect(body.value).toBe(complexValue);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Users API Tests List / Get / Update / Delete (password_hash stripping)
*/
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('Users API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let testUserId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a test user directly via DB (bypassing AuthService)
const user = db.createUser({
email: 'test-admin@example.com',
password_hash: 'fake-hash-for-testing',
name: 'Test Admin',
role: 'admin',
});
testUserId = user.id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── List ───────────────────────────────────────────
describe('GET /api/users', () => {
it('should list all users', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user
});
it('should strip password_hash from list responses', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
const body = JSON.parse(response.body);
for (const user of body) {
expect(user.password_hash).toBeUndefined();
}
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/users/:id', () => {
it('should get a single user', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(testUserId);
expect(body.email).toBe('test-admin@example.com');
expect(body.name).toBe('Test Admin');
expect(body.role).toBe('admin');
});
it('should strip password_hash from single user response', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should return 404 for non-existent user', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/users/:id', () => {
it('should update user name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { name: 'Updated Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Name');
expect(body.id).toBe(testUserId);
});
it('should update user role', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { role: 'planer' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.role).toBe('planer');
});
it('should strip password_hash from update response', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { name: 'Another Name' },
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should not update password_hash via PATCH endpoint', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { password_hash: 'hacked-hash' },
});
expect(response.statusCode).toBe(200);
// Verify the password_hash was NOT changed in DB
const dbUser = db.getUser(testUserId);
expect(dbUser!.password_hash).toBe('fake-hash-for-testing');
});
it('should return 404 for non-existent user update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/users/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/users/:id', () => {
it('should delete a user', async () => {
// Create a user to delete
const user = db.createUser({
email: 'delete-me@example.com',
password_hash: 'temp-hash',
name: 'Delete Me',
role: 'planer',
});
const response = await app.inject({
method: 'DELETE',
url: `/api/users/${user.id}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/users/${user.id}`,
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent user delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/users/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});