Web CAD - complete TypeScript source (React 18 frontend, Node.js backend, CRDT collaboration, KI Copilot)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
*.log
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache wget
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/index.js"]
|
||||
Generated
+3888
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web-cad-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/static": "^7.0.0",
|
||||
"@fastify/websocket": "^10.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0",
|
||||
"y-leveldb": "^0.2.0",
|
||||
"yjs": "^13.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* AuthService – Registration, Login, Session Management
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { DatabaseInterface, DBUser, DBSession } from '../database/DatabaseInterface.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
user: Omit<DBUser, 'password_hash'>;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(private db: DatabaseInterface) {}
|
||||
|
||||
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
|
||||
const existing = this.db.getUserByEmail(email);
|
||||
if (existing) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const user = this.db.createUser({ email, password_hash, name, role });
|
||||
const session = this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), session };
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResult> {
|
||||
const user = this.db.getUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const session = this.createSession(user.id);
|
||||
return { user: this.sanitizeUser(user), session };
|
||||
}
|
||||
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
const user = this.db.getUser(userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!valid) throw new Error('Invalid current password');
|
||||
|
||||
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
||||
this.db.updateUser(userId, { password_hash });
|
||||
}
|
||||
|
||||
createSession(userId: string): Session {
|
||||
const token = randomUUID();
|
||||
const expiresAt = Date.now() + SESSION_TTL_MS;
|
||||
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
|
||||
return { token, userId, expiresAt };
|
||||
}
|
||||
|
||||
validateSession(token: string): Session | null {
|
||||
const dbSession = this.db.getSession(token);
|
||||
if (!dbSession) return null;
|
||||
if (Date.now() > dbSession.expires_at) {
|
||||
this.db.deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
token: dbSession.token,
|
||||
userId: dbSession.user_id,
|
||||
expiresAt: dbSession.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
destroySession(token: string): void {
|
||||
this.db.deleteSession(token);
|
||||
}
|
||||
|
||||
getUserFromSession(token: string): DBUser | null {
|
||||
const session = this.validateSession(token);
|
||||
if (!session) return null;
|
||||
return this.db.getUser(session.userId);
|
||||
}
|
||||
|
||||
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Database Interface – abstract DB layer for Web CAD
|
||||
*/
|
||||
|
||||
export interface DBProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBDrawing {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
data_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBLayer {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
name: string;
|
||||
visible: number;
|
||||
locked: number;
|
||||
color: string;
|
||||
line_type: string;
|
||||
transparency: number;
|
||||
sort_order: number;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
export interface DBElement {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
layer_id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
properties_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DBBlock {
|
||||
id: string;
|
||||
drawing_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
elements_json: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface DBSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBUser {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
name: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DBSession {
|
||||
token: string;
|
||||
user_id: string;
|
||||
expires_at: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DatabaseInterface {
|
||||
init(): Promise<void>;
|
||||
close(): void;
|
||||
|
||||
// Users
|
||||
getUser(id: string): DBUser | null;
|
||||
getUserByEmail(email: string): DBUser | null;
|
||||
createUser(data: Partial<DBUser>): DBUser;
|
||||
updateUser(id: string, data: Partial<DBUser>): DBUser | null;
|
||||
deleteUser(id: string): boolean;
|
||||
listUsers(): DBUser[];
|
||||
|
||||
// Projects
|
||||
listProjects(): DBProject[];
|
||||
getProject(id: string): DBProject | null;
|
||||
createProject(data: Partial<DBProject>): DBProject;
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
|
||||
deleteProject(id: string): boolean;
|
||||
|
||||
// Drawings
|
||||
listDrawings(projectId: string): DBDrawing[];
|
||||
getDrawing(id: string): DBDrawing | null;
|
||||
createDrawing(data: Partial<DBDrawing>): DBDrawing;
|
||||
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null;
|
||||
deleteDrawing(id: string): boolean;
|
||||
|
||||
// Layers
|
||||
listLayers(drawingId: string): DBLayer[];
|
||||
createLayer(data: Partial<DBLayer>): DBLayer;
|
||||
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null;
|
||||
deleteLayer(id: string): boolean;
|
||||
|
||||
// Elements
|
||||
listElements(drawingId: string): DBElement[];
|
||||
createElement(data: Partial<DBElement>): DBElement;
|
||||
updateElement(id: string, data: Partial<DBElement>): DBElement | null;
|
||||
deleteElement(id: string): boolean;
|
||||
|
||||
// Blocks
|
||||
listBlocks(drawingId: string): DBBlock[];
|
||||
createBlock(data: Partial<DBBlock>): DBBlock;
|
||||
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null;
|
||||
deleteBlock(id: string): boolean;
|
||||
|
||||
// Settings
|
||||
getSetting(key: string): DBSetting | null;
|
||||
setSetting(key: string, value: string): void;
|
||||
|
||||
// Sessions
|
||||
createSession(data: Partial<DBSession>): DBSession;
|
||||
getSession(token: string): DBSession | null;
|
||||
deleteSession(token: string): boolean;
|
||||
deleteExpiredSessions(): void;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* SQLite Adapter – implements DatabaseInterface with better-sqlite3
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
DatabaseInterface, DBProject, DBDrawing, DBLayer,
|
||||
DBElement, DBBlock, DBSetting, DBUser, DBSession,
|
||||
} from './DatabaseInterface.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export class SqliteAdapter implements DatabaseInterface {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(dbPath: string = ':memory:') {
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const schemaPath = join(__dirname, 'schema.sql');
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
this.db.exec(schema);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
// ─── Users ──────────────────────────────────────────
|
||||
getUser(id: string): DBUser | null {
|
||||
return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null;
|
||||
}
|
||||
|
||||
getUserByEmail(email: string): DBUser | null {
|
||||
return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null;
|
||||
}
|
||||
|
||||
createUser(data: Partial<DBUser>): DBUser {
|
||||
const id = data.id ?? `user-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
|
||||
return this.getUser(id)!;
|
||||
}
|
||||
|
||||
updateUser(id: string, data: Partial<DBUser>): DBUser | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); }
|
||||
if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); }
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getUser(id);
|
||||
}
|
||||
|
||||
deleteUser(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
listUsers(): DBUser[] {
|
||||
return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[];
|
||||
}
|
||||
|
||||
// ─── Projects ──────────────────────────────────────
|
||||
listProjects(): DBProject[] {
|
||||
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
|
||||
}
|
||||
|
||||
getProject(id: string): DBProject | null {
|
||||
return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null;
|
||||
}
|
||||
|
||||
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');
|
||||
return this.getProject(id)!;
|
||||
}
|
||||
|
||||
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getProject(id);
|
||||
}
|
||||
|
||||
deleteProject(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM projects 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[];
|
||||
}
|
||||
|
||||
getDrawing(id: string): DBDrawing | null {
|
||||
return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null;
|
||||
}
|
||||
|
||||
createDrawing(data: Partial<DBDrawing>): DBDrawing {
|
||||
const id = data.id ?? `draw-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
|
||||
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
|
||||
return this.getDrawing(id)!;
|
||||
}
|
||||
|
||||
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
|
||||
if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); }
|
||||
sets.push("updated_at = datetime('now')");
|
||||
if (sets.length > 1) {
|
||||
this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.getDrawing(id);
|
||||
}
|
||||
|
||||
deleteDrawing(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Layers ────────────────────────────────────────
|
||||
listLayers(drawingId: string): DBLayer[] {
|
||||
return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[];
|
||||
}
|
||||
|
||||
createLayer(data: Partial<DBLayer>): DBLayer {
|
||||
const id = data.id ?? `layer-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0,
|
||||
data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0,
|
||||
data.sort_order ?? 0, data.parent_id ?? null);
|
||||
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer;
|
||||
}
|
||||
|
||||
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null;
|
||||
}
|
||||
|
||||
deleteLayer(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Elements ──────────────────────────────────────
|
||||
listElements(drawingId: string): DBElement[] {
|
||||
return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[];
|
||||
}
|
||||
|
||||
createElement(data: Partial<DBElement>): DBElement {
|
||||
const id = data.id ?? `elem-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
|
||||
data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}');
|
||||
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement;
|
||||
}
|
||||
|
||||
updateElement(id: string, data: Partial<DBElement>): DBElement | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null;
|
||||
}
|
||||
|
||||
deleteElement(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Blocks ────────────────────────────────────────
|
||||
listBlocks(drawingId: string): DBBlock[] {
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[];
|
||||
}
|
||||
|
||||
createBlock(data: Partial<DBBlock>): DBBlock {
|
||||
const id = data.id ?? `block-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
|
||||
data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null);
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock;
|
||||
}
|
||||
|
||||
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null {
|
||||
const sets: string[] = [];
|
||||
const vals: any[] = [];
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (['name','description','category','elements_json','thumbnail'].includes(k)) {
|
||||
sets.push(`${k} = ?`); vals.push(v);
|
||||
}
|
||||
}
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
||||
}
|
||||
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null;
|
||||
}
|
||||
|
||||
deleteBlock(id: string): boolean {
|
||||
return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
// ─── Settings ──────────────────────────────────────
|
||||
getSetting(key: string): DBSetting | null {
|
||||
return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null;
|
||||
}
|
||||
|
||||
setSetting(key: string, value: string): void {
|
||||
this.db.prepare(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
|
||||
).run(key, value);
|
||||
}
|
||||
|
||||
// ─── Sessions ───────────────────────────────────────
|
||||
createSession(data: Partial<DBSession>): DBSession {
|
||||
const token = data.token ?? randomUUID();
|
||||
this.db.prepare(
|
||||
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
||||
).run(token, data.user_id!, data.expires_at!);
|
||||
return this.getSession(token)!;
|
||||
}
|
||||
|
||||
getSession(token: string): DBSession | null {
|
||||
return (this.db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) as DBSession) ?? null;
|
||||
}
|
||||
|
||||
deleteSession(token: string): boolean {
|
||||
return this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token).changes > 0;
|
||||
}
|
||||
|
||||
deleteExpiredSessions(): void {
|
||||
this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Web CAD Backend – SQLite Schema
|
||||
-- Version 1.0
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ─── Users ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Default User (seed) ────────────────────────────
|
||||
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
|
||||
|
||||
-- ─── Projects ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Drawings ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS drawings (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Layers ─────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS layers (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT '#ffffff',
|
||||
line_type TEXT NOT NULL DEFAULT 'solid',
|
||||
transparency REAL NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id TEXT,
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Elements ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
width REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
properties_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (layer_id) REFERENCES layers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Block Definitions ──────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawing_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Allgemein',
|
||||
elements_json TEXT NOT NULL DEFAULT '[]',
|
||||
thumbnail TEXT,
|
||||
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Sessions ──────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ─── Settings ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Web CAD Backend – Entry Point
|
||||
*/
|
||||
import { createServer } from './server.js';
|
||||
import { SqliteAdapter } from './database/SqliteAdapter.js';
|
||||
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
|
||||
|
||||
async function main() {
|
||||
const db = new SqliteAdapter(DB_PATH);
|
||||
await db.init();
|
||||
await initYjsPersistence();
|
||||
|
||||
const fastify = await createServer({ port: PORT, host: HOST, db });
|
||||
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: HOST });
|
||||
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await fastify.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
await fastify.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* AI Routes – KI Copilot chat endpoint via OpenRouter
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
const MODEL = 'anthropic/claude-sonnet-4.5';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
fastify.post('/api/ai/chat', async (request, reply) => {
|
||||
// Auth check
|
||||
const token = extractToken(request);
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
const { messages, context } = request.body as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
context?: {
|
||||
projectName?: string;
|
||||
elementCount?: number;
|
||||
layerCount?: number;
|
||||
elementTypeSummary?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return reply.code(400).send({ error: 'Messages array is required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.API_KEY_OPENROUTER;
|
||||
if (!apiKey) {
|
||||
return reply.code(503).send({ error: 'AI service not configured' });
|
||||
}
|
||||
|
||||
// Build system prompt with CAD context
|
||||
const ctxParts: string[] = [
|
||||
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
|
||||
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
|
||||
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
|
||||
];
|
||||
|
||||
if (context) {
|
||||
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
|
||||
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
|
||||
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
|
||||
if (context.elementTypeSummary) {
|
||||
const summary = Object.entries(context.elementTypeSummary)
|
||||
.map(([type, count]) => `${type}: ${count}`)
|
||||
.join(', ');
|
||||
ctxParts.push(`Element-Typen: ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = ctxParts.join('\n');
|
||||
|
||||
// Build OpenRouter request
|
||||
const payload = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': 'http://localhost:5173',
|
||||
'X-Title': 'Web CAD KI Copilot',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
request.log.error({ errText, status: response.status }, 'OpenRouter error');
|
||||
return reply.code(502).send({ error: 'AI service returned an error' });
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
|
||||
if (!content) {
|
||||
return reply.code(502).send({ error: 'AI service returned empty response' });
|
||||
}
|
||||
|
||||
// Extract suggestions from response if present (simple heuristic)
|
||||
const suggestions: string[] = [];
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^[-*]\s*(.+)/);
|
||||
if (match && suggestions.length < 3) {
|
||||
suggestions.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
return reply.code(504).send({ error: 'AI service timeout' });
|
||||
}
|
||||
request.log.error({ err }, 'AI route error');
|
||||
return reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Auth Routes – Registration, Login, Logout, Profile
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
// Register new user
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const { email, password, name, role } = request.body as {
|
||||
email?: string; password?: string; name?: string; role?: string;
|
||||
};
|
||||
if (!email || !password || !name) {
|
||||
return reply.code(400).send({ error: 'Email, password and name are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.register(email, password, name, role);
|
||||
return reply.code(201).send(result);
|
||||
} catch (err: any) { return reply.code(409).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const { email, password } = request.body as { email?: string; password?: string };
|
||||
if (!email || !password) {
|
||||
return reply.code(400).send({ error: 'Email and password are required' });
|
||||
}
|
||||
try {
|
||||
const result = await authService.login(email, password);
|
||||
return reply.send(result);
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
fastify.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (token) {
|
||||
authService.destroySession(token);
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
fastify.get('/api/auth/me', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Change password
|
||||
fastify.patch('/api/auth/password', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
|
||||
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
|
||||
if (!oldPassword || !newPassword) {
|
||||
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
|
||||
}
|
||||
try {
|
||||
await authService.changePassword(user.id, oldPassword, newPassword);
|
||||
return reply.code(204).send();
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractToken(request: any): string | null {
|
||||
const auth = request.headers?.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Blocks Routes – CRUD for reusable drawing blocks
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all blocks for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/blocks', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listBlocks(drawingId);
|
||||
});
|
||||
|
||||
// Create a new block
|
||||
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update a block
|
||||
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBBlock>;
|
||||
const updated = db.updateBlock(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete a block
|
||||
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteBlock(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Drawings Routes – CRUD for drawings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List drawings for a project
|
||||
fastify.get('/api/projects/:projectId/drawings', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
return db.listDrawings(projectId);
|
||||
});
|
||||
|
||||
// Get single drawing
|
||||
fastify.get('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const drawing = db.getDrawing(id);
|
||||
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return drawing;
|
||||
});
|
||||
|
||||
// Create drawing
|
||||
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
||||
});
|
||||
|
||||
// Update drawing
|
||||
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBDrawing>;
|
||||
const updated = db.updateDrawing(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete drawing
|
||||
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteDrawing(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Elements Routes – CRUD for elements
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List elements for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/elements', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listElements(drawingId);
|
||||
});
|
||||
|
||||
// Create element
|
||||
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update element
|
||||
fastify.patch('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBElement>;
|
||||
const updated = db.updateElement(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete element
|
||||
fastify.delete('/api/elements/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteElement(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Layers Routes – CRUD for layers
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List layers for a drawing
|
||||
fastify.get('/api/drawings/:drawingId/layers', async (request) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
return db.listLayers(drawingId);
|
||||
});
|
||||
|
||||
// Create layer
|
||||
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
||||
const { drawingId } = request.params as { drawingId: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
||||
});
|
||||
|
||||
// Update layer
|
||||
fastify.patch('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBLayer>;
|
||||
const updated = db.updateLayer(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete layer
|
||||
fastify.delete('/api/layers/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteLayer(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Projects Routes – CRUD for projects
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// List all projects
|
||||
fastify.get('/api/projects', async () => {
|
||||
return db.listProjects();
|
||||
});
|
||||
|
||||
// Get single project
|
||||
fastify.get('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const project = db.getProject(id);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
return project;
|
||||
});
|
||||
|
||||
// Create project
|
||||
fastify.post('/api/projects', async (request, reply) => {
|
||||
const body = request.body as Partial<DBProject>;
|
||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
||||
return reply.code(201).send(db.createProject(body));
|
||||
});
|
||||
|
||||
// Update project
|
||||
fastify.patch('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBProject>;
|
||||
const updated = db.updateProject(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete project
|
||||
fastify.delete('/api/projects/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteProject(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Settings Routes – key/value application settings
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||
|
||||
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
|
||||
// Get a single setting by key
|
||||
fastify.get('/api/settings/:key', async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const setting = db.getSetting(key);
|
||||
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
||||
return setting;
|
||||
});
|
||||
|
||||
// Create or update a setting (upsert)
|
||||
fastify.put('/api/settings/:key', async (request) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const body = request.body as { value?: string };
|
||||
if (body.value === undefined) {
|
||||
return { error: 'Value is required' };
|
||||
}
|
||||
db.setSetting(key, body.value);
|
||||
return { key, value: body.value, updated_at: new Date().toISOString() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Users Routes – Admin user management
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
|
||||
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// List all users (admin only — TODO: add role check middleware)
|
||||
fastify.get('/api/users', async () => {
|
||||
const users = db.listUsers();
|
||||
return users.map(({ password_hash, ...safe }) => safe);
|
||||
});
|
||||
|
||||
// Get single user
|
||||
fastify.get('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = db.getUser(id);
|
||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = user;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Update user (name, role, email)
|
||||
fastify.patch('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as Partial<DBUser>;
|
||||
// Prevent password update via this endpoint
|
||||
delete body.password_hash;
|
||||
const updated = db.updateUser(id, body);
|
||||
if (!updated) return reply.code(404).send({ error: 'User not found' });
|
||||
const { password_hash, ...safe } = updated;
|
||||
return safe;
|
||||
});
|
||||
|
||||
// Delete user
|
||||
fastify.delete('/api/users/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const ok = db.deleteUser(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Server – Fastify configuration with CORS, static, and WebSocket
|
||||
*/
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import staticPlugin from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { DatabaseInterface } from './database/DatabaseInterface.js';
|
||||
import { AuthService } from './auth/AuthService.js';
|
||||
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface ServerOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
db: DatabaseInterface;
|
||||
}
|
||||
|
||||
export async function createServer(opts: ServerOptions) {
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
// CORS
|
||||
await fastify.register(cors, {
|
||||
origin: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
await fastify.register(websocket, {
|
||||
options: { maxPayload: 1048576 },
|
||||
});
|
||||
|
||||
// Static files (serve frontend build if exists)
|
||||
const frontendDist = join(__dirname, '..', '..', 'frontend', 'dist');
|
||||
try {
|
||||
await fastify.register(staticPlugin, {
|
||||
root: frontendDist,
|
||||
prefix: '/',
|
||||
});
|
||||
} catch {
|
||||
// Frontend dist may not exist in dev mode
|
||||
}
|
||||
|
||||
// Health check
|
||||
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||
|
||||
// Register route modules
|
||||
const { registerProjectRoutes } = await import('./routes/projects.js');
|
||||
const { registerDrawingRoutes } = await import('./routes/drawings.js');
|
||||
const { registerLayerRoutes } = await import('./routes/layers.js');
|
||||
const { registerElementRoutes } = await import('./routes/elements.js');
|
||||
const { registerBlockRoutes } = await import('./routes/blocks.js');
|
||||
const { registerSettingsRoutes } = await import('./routes/settings.js');
|
||||
const { registerAuthRoutes } = await import('./routes/auth.js');
|
||||
const { registerUserRoutes } = await import('./routes/users.js');
|
||||
const { registerAIRoutes } = await import('./routes/ai.js');
|
||||
|
||||
const authService = new AuthService(opts.db);
|
||||
|
||||
registerAuthRoutes(fastify, authService);
|
||||
registerProjectRoutes(fastify, opts.db);
|
||||
registerDrawingRoutes(fastify, opts.db);
|
||||
registerLayerRoutes(fastify, opts.db);
|
||||
registerElementRoutes(fastify, opts.db);
|
||||
registerBlockRoutes(fastify, opts.db);
|
||||
registerSettingsRoutes(fastify, opts.db);
|
||||
registerUserRoutes(fastify, opts.db, authService);
|
||||
registerAIRoutes(fastify, authService);
|
||||
|
||||
// Yjs collaboration WebSocket
|
||||
registerYjsWebSocket(fastify);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
declare module 'y-leveldb' {
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
export class LeveldbPersistence {
|
||||
constructor(dbPath: string, opts?: Record<string, unknown>);
|
||||
getYDoc(name: string): Promise<Y.Doc>;
|
||||
storeUpdate(name: string, update: Uint8Array): Promise<number>;
|
||||
destroy(): Promise<void>;
|
||||
clearDocument(name: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Yjs WebSocket Server – Real-time collaboration with LevelDB persistence
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { LeveldbPersistence } from 'y-leveldb';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
|
||||
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
||||
|
||||
// Document store: docName → Y.Doc
|
||||
const docs = new Map<string, Y.Doc>();
|
||||
// Connection tracking: docName → Set<WebSocket>
|
||||
const connections = new Map<string, Set<WebSocket>>();
|
||||
let persistence: LeveldbPersistence | null = null;
|
||||
let persistenceReady: Promise<void> | null = null;
|
||||
|
||||
export async function initYjsPersistence(): Promise<void> {
|
||||
try {
|
||||
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
||||
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
||||
} catch (err) {
|
||||
console.warn('Yjs persistence failed to init (non-fatal):', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeYjsPersistence(): Promise<void> {
|
||||
if (persistence) {
|
||||
await persistence.destroy();
|
||||
persistence = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
||||
if (docs.has(docName)) {
|
||||
return docs.get(docName)!;
|
||||
}
|
||||
|
||||
const doc = new Y.Doc();
|
||||
docs.set(docName, doc);
|
||||
|
||||
// Load persisted state if available
|
||||
if (persistence && persistenceReady) {
|
||||
try {
|
||||
await persistenceReady;
|
||||
const persistedYdoc = await persistence.getYDoc(docName);
|
||||
const update = Y.encodeStateAsUpdate(persistedYdoc);
|
||||
if (update.length > 0) {
|
||||
Y.applyUpdate(doc, update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load doc ${docName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updates to LevelDB
|
||||
doc.on('update', (update: Uint8Array) => {
|
||||
if (persistence) {
|
||||
persistence.storeUpdate(docName, update).catch((err: any) => {
|
||||
console.warn(`Failed to persist update for ${docName}:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
|
||||
const conns = connections.get(docName);
|
||||
if (!conns) return;
|
||||
|
||||
for (const ws of conns) {
|
||||
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
|
||||
try {
|
||||
ws.send(update);
|
||||
} catch {
|
||||
// Connection error — will be cleaned up on close
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerYjsWebSocket(fastify: FastifyInstance): void {
|
||||
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
|
||||
const docName = request.params.docName as string;
|
||||
if (!docName) {
|
||||
socket.close(4000, 'Missing docName');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await getOrCreateDoc(docName);
|
||||
|
||||
// Track connection
|
||||
if (!connections.has(docName)) {
|
||||
connections.set(docName, new Set());
|
||||
}
|
||||
connections.get(docName)!.add(socket);
|
||||
|
||||
// Send current document state to new client
|
||||
const stateUpdate = Y.encodeStateAsUpdate(doc);
|
||||
if (stateUpdate.length > 0 && socket.readyState === 1) {
|
||||
socket.send(stateUpdate);
|
||||
}
|
||||
|
||||
// Listen for updates from this client
|
||||
socket.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const update = new Uint8Array(data);
|
||||
Y.applyUpdate(doc, update);
|
||||
broadcastUpdate(docName, update, socket);
|
||||
} catch (err) {
|
||||
console.warn(`Invalid update from client for ${docName}:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on disconnect
|
||||
socket.on('close', () => {
|
||||
const conns = connections.get(docName);
|
||||
if (conns) {
|
||||
conns.delete(socket);
|
||||
if (conns.size === 0) {
|
||||
connections.delete(docName);
|
||||
// Optionally keep doc in memory for a while
|
||||
// For now, remove it to free memory
|
||||
docs.delete(docName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
const conns = connections.get(docName);
|
||||
if (conns) {
|
||||
conns.delete(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Backend Stresstest – 50.000 Elemente: SQLite CRUD Performance,
|
||||
* Bulk-Insert, List, Update, Delete mit Transaktion-Support.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import type { DBElement } from '../src/database/DatabaseInterface.js';
|
||||
|
||||
const N = 50_000;
|
||||
|
||||
describe('Backend Stresstest: 50.000 Elemente in SQLite', () => {
|
||||
let db: SqliteAdapter;
|
||||
let drawingId: string;
|
||||
let layerId: string;
|
||||
let elementIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
|
||||
// Create prerequisite project → drawing → layer
|
||||
const project = db.createProject({ name: 'Stresstest Project' });
|
||||
const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' });
|
||||
drawingId = drawing.id;
|
||||
const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' });
|
||||
layerId = layer.id;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('should bulk-insert 50k elements in under 5s', () => {
|
||||
const start = performance.now();
|
||||
// Use transaction for bulk insert
|
||||
const insert = db['db'].prepare(
|
||||
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => {
|
||||
for (const el of elements) {
|
||||
insert.run(...el);
|
||||
}
|
||||
});
|
||||
|
||||
const batch: Array<[string, string, string, string, number, number, number, number, string]> = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const id = `stress-elem-${i}`;
|
||||
elementIds.push(id);
|
||||
batch.push([
|
||||
id,
|
||||
drawingId,
|
||||
layerId,
|
||||
'rect',
|
||||
(i % 1000) * 1.5,
|
||||
Math.floor(i / 1000) * 1.5,
|
||||
1,
|
||||
1,
|
||||
'{}',
|
||||
]);
|
||||
}
|
||||
insertMany(batch);
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should list 50k elements in under 500ms', () => {
|
||||
const start = performance.now();
|
||||
const elements = db.listElements(drawingId);
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`);
|
||||
expect(elements.length).toBe(N);
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('should update 100 elements in under 200ms', () => {
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
db.updateElement(elementIds[i], { x: 9999 + i });
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`Update 100 elements: ${elapsed.toFixed(1)}ms`);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should read a single element in under 5ms', () => {
|
||||
const start = performance.now();
|
||||
const element = db.listElements(drawingId);
|
||||
const mid = element[Math.floor(element.length / 2)];
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`Read mid element from ${N}: ${elapsed.toFixed(1)}ms`);
|
||||
expect(mid).toBeDefined();
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('should delete 1000 elements in under 500ms', () => {
|
||||
const start = performance.now();
|
||||
const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?');
|
||||
const deleteMany = db['db'].transaction((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
deleteStmt.run(id);
|
||||
}
|
||||
});
|
||||
const toDelete = elementIds.slice(0, 1000);
|
||||
deleteMany(toDelete);
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`);
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
|
||||
// Verify count
|
||||
const remaining = db.listElements(drawingId);
|
||||
expect(remaining.length).toBe(N - 1000);
|
||||
});
|
||||
|
||||
it('should handle concurrent queries efficiently', () => {
|
||||
// Simulate 10 concurrent list operations
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const elements = db.listElements(drawingId);
|
||||
expect(elements.length).toBe(N - 1000);
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`10x list operations on ${N - 1000} elements: ${elapsed.toFixed(1)}ms`);
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Auth API Tests – Register / Login / Logout / Me / Password Change
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Auth API', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let authToken: string | null = null;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// ─── Register ────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('should register a new user with 201', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
name: 'Test User',
|
||||
role: 'planer',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.email).toBe('testuser@example.com');
|
||||
expect(body.user.name).toBe('Test User');
|
||||
expect(body.user.password_hash).toBeUndefined();
|
||||
expect(body.session).toBeDefined();
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject duplicate registration with 409', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
name: 'Test User 2',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.error).toContain('already registered');
|
||||
});
|
||||
|
||||
it('should reject missing fields with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'incomplete@example.com' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login ──────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('should login with correct credentials', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject wrong password with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'WrongPassword!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject non-existent email with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'nobody@example.com',
|
||||
password: 'SomePassword!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject missing fields with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: { email: 'testuser@example.com' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Me ─────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
it('should return current user profile with valid token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.email).toBe('testuser@example.com');
|
||||
expect(body.password_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject without token with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject invalid token with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: 'Bearer invalid-token-xxx' },
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password Change ───────────────────────────────
|
||||
|
||||
describe('PATCH /api/auth/password', () => {
|
||||
it('should change password with correct old password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {
|
||||
oldPassword: 'TestPass123!',
|
||||
newPassword: 'NewPassword456!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('should allow login with new password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'NewPassword456!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject old password after change', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject password change with wrong old password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {
|
||||
oldPassword: 'WrongOldPassword!',
|
||||
newPassword: 'AnotherNew789!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject password change without auth', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
payload: {
|
||||
oldPassword: 'NewPassword456!',
|
||||
newPassword: 'AnotherNew789!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject missing password fields', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { oldPassword: 'NewPassword456!' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Logout ─────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
it('should logout and invalidate token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Token should now be invalid
|
||||
const meResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(meResponse.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 204 even without token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Health Endpoint Tests
|
||||
*/
|
||||
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('Health Endpoint', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('GET /api/health should return ok status', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/health',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/health should return valid ISO timestamp', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/health',
|
||||
});
|
||||
const body = JSON.parse(response.body);
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toString()).not.toBe('Invalid Date');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Projects 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('Projects API', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let createdProjectId: string | null = null;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// ─── Create ─────────────────────────────────────────
|
||||
|
||||
describe('POST /api/projects', () => {
|
||||
it('should create a project with 201', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: {
|
||||
name: 'Test Project',
|
||||
description: 'A test project',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.id).toBeDefined();
|
||||
expect(body.name).toBe('Test Project');
|
||||
expect(body.description).toBe('A test project');
|
||||
createdProjectId = body.id;
|
||||
});
|
||||
|
||||
it('should reject project without name with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { description: 'No name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should create project with default values', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { name: 'Minimal Project' },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Minimal Project');
|
||||
expect(body.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── List ───────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects', () => {
|
||||
it('should list all projects', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/projects',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Get ────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects/:id', () => {
|
||||
it('should get a single project', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.id).toBe(createdProjectId);
|
||||
expect(body.name).toBe('Test Project');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/projects/non-existent-id',
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update ─────────────────────────────────────────
|
||||
|
||||
describe('PATCH /api/projects/:id', () => {
|
||||
it('should update project name', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
payload: { name: 'Updated Project Name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Updated Project Name');
|
||||
expect(body.id).toBe(createdProjectId);
|
||||
});
|
||||
|
||||
it('should update project description', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
payload: { description: 'Updated description' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project update', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/projects/non-existent-id',
|
||||
payload: { name: 'New Name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/projects/:id', () => {
|
||||
it('should delete a project', async () => {
|
||||
// First create a project to delete
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { name: 'To Be Deleted' },
|
||||
});
|
||||
const projectId = JSON.parse(createRes.body).id;
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/projects/${projectId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Verify it's gone
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${projectId}`,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project delete', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/projects/non-existent-id',
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/types/**', 'src/websocket/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user