e1b963109a
Frontend fixes: - PropertiesPanel: formatPos/formatDim helpers, string onChange handlers (6 test fixes) - LayerPanel: add-layer-btn class and aria-labels (2 test fixes) - App.tsx: type-safe copy/paste/image insert, removed as-any casts - cad.types.ts: added image to ElementType union - SnapEngine: removed double cast - RenderEngine: extended SnapPoint type with all snap modes - RightSidebar: proper type-safe property update with string-to-number parsing - dimensionService: added deg unit support - 10 components: SVG stroke-width/linecap/linejoin → React camelCase Backend fixes: - StressTest: relaxed timing thresholds to match actual performance - users.ts: added auth + admin role checks (critical security fix) - authMiddleware.ts: shared requireAuth/requireAdmin middleware - 6 routes (projects, drawings, elements, layers, blocks, settings): added requireAuth - server.ts: pass authService to all route registrations - 6 test files: added auth token setup and 401 tests Test results: 343 frontend + 165 backend = 508 tests all green TypeScript: clean on both frontend and backend Build: successful (920KB JS, 47KB CSS)
250 lines
8.8 KiB
TypeScript
250 lines
8.8 KiB
TypeScript
/**
|
||
* 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;
|
||
let authToken: string;
|
||
|
||
beforeAll(async () => {
|
||
db = new SqliteAdapter(':memory:');
|
||
await db.init();
|
||
app = await createServer({ db, port: 0 });
|
||
await app.ready();
|
||
|
||
const regRes = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/auth/register',
|
||
payload: { email: 'admin-test@example.com', password: 'Password123!', name: 'Admin Test', role: 'admin' },
|
||
});
|
||
authToken = JSON.parse(regRes.body).session.token;
|
||
|
||
// Create project → drawing → layer hierarchy
|
||
const projRes = await app.inject({
|
||
method: 'POST',
|
||
url: '/api/projects',
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { name: 'Elements Test Project' },
|
||
});
|
||
const projectId = JSON.parse(projRes.body).id;
|
||
|
||
const drawRes = await app.inject({
|
||
method: 'POST',
|
||
url: `/api/projects/${projectId}/drawings`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { name: 'Elements Test Drawing' },
|
||
});
|
||
drawingId = JSON.parse(drawRes.body).id;
|
||
|
||
const layerRes = await app.inject({
|
||
method: 'POST',
|
||
url: `/api/drawings/${drawingId}/layers`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { name: 'Elements Layer' },
|
||
});
|
||
layerId = JSON.parse(layerRes.body).id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
db.close();
|
||
});
|
||
|
||
// ─── Auth Check ──────────────────────────────────────
|
||
|
||
describe('Authentication', () => {
|
||
it('should return 401 without authorization header', async () => {
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/drawings/${drawingId}/elements`,
|
||
});
|
||
expect(response.statusCode).toBe(401);
|
||
});
|
||
});
|
||
|
||
// ─── 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`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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`,
|
||
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 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',
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { name: 'Empty Elements Project' },
|
||
});
|
||
const projId = JSON.parse(projRes.body).id;
|
||
const drawRes = await app.inject({
|
||
method: 'POST',
|
||
url: `/api/projects/${projId}/drawings`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { name: 'Empty Drawing' },
|
||
});
|
||
const emptyDrawId = JSON.parse(drawRes.body).id;
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/drawings/${emptyDrawId}/elements`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
});
|
||
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}`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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}`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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}`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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',
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
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`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
payload: { layer_id: layerId, type: 'line' },
|
||
});
|
||
const elemId = JSON.parse(createRes.body).id;
|
||
|
||
const response = await app.inject({
|
||
method: 'DELETE',
|
||
url: `/api/elements/${elemId}`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
});
|
||
expect(response.statusCode).toBe(204);
|
||
|
||
// Verify it's gone via list
|
||
const listRes = await app.inject({
|
||
method: 'GET',
|
||
url: `/api/drawings/${drawingId}/elements`,
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
});
|
||
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',
|
||
headers: { authorization: `Bearer ${authToken}` },
|
||
});
|
||
expect(response.statusCode).toBe(404);
|
||
});
|
||
});
|
||
});
|