Clean working version: deployed TypeScript backend + JSX frontend
- Replace old JS backend with working TypeScript backend from deployed containers - Backend: Fastify + better-sqlite3 + Yjs CRDT, 17 TS source files, 13 test files - Frontend: React JSX with fabric.js CAD canvas, vite build, nginx serving - Fix nginx proxy port 5000→3001 to match backend - Fix docker-compose port mapping 5000→3001 - Fix vite dev proxy port 5000→3001 - Add backend healthcheck to docker-compose - Update index.html title to 'Web CAD', lang to 'de' - Add .a0/ to .gitignore - Remove old JS backend files (models, routes, config, middleware, seed) - Remove tracked build artifacts (backend/public/)
This commit is contained in:
@@ -4,3 +4,4 @@ dist/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.db
|
*.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.a0/
|
||||||
|
|||||||
+7
-11
@@ -1,13 +1,9 @@
|
|||||||
FROM node:22-alpine
|
FROM node:20-alpine
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache wget
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --only=production
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
EXPOSE 5000
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
const { Sequelize } = require('sequelize');
|
|
||||||
|
|
||||||
const sequelize = new Sequelize(
|
|
||||||
process.env.DB_NAME || 'webcad',
|
|
||||||
process.env.DB_USER || 'webcad',
|
|
||||||
process.env.DB_PASSWORD || 'webcad',
|
|
||||||
{
|
|
||||||
host: process.env.DB_HOST || 'postgres',
|
|
||||||
port: process.env.DB_PORT || 5432,
|
|
||||||
dialect: 'postgres',
|
|
||||||
logging: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const connectDB = async () => {
|
|
||||||
try {
|
|
||||||
await sequelize.authenticate();
|
|
||||||
console.log('PostgreSQL connected.');
|
|
||||||
await sequelize.sync();
|
|
||||||
console.log('Database synced.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('DB connection error:', err.message);
|
|
||||||
setTimeout(connectDB, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { sequelize, connectDB };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const passport = require('passport');
|
|
||||||
const JwtStrategy = require('passport-jwt').Strategy;
|
|
||||||
const ExtractJwt = require('passport-jwt').ExtractJwt;
|
|
||||||
const User = require('../models/User');
|
|
||||||
|
|
||||||
const opts = {
|
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
secretOrKey: process.env.JWT_SECRET || 'dev_secret_change_in_production',
|
|
||||||
};
|
|
||||||
|
|
||||||
passport.use(new JwtStrategy(opts, async (jwt_payload, done) => {
|
|
||||||
try {
|
|
||||||
const user = await User.findByPk(jwt_payload.id);
|
|
||||||
if (user) {
|
|
||||||
return done(null, user);
|
|
||||||
}
|
|
||||||
return done(null, false);
|
|
||||||
} catch (err) {
|
|
||||||
return done(err, false);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
module.exports = passport;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const passport = require('passport');
|
|
||||||
|
|
||||||
// Protect routes with JWT
|
|
||||||
const authenticate = passport.authenticate('jwt', { session: false });
|
|
||||||
|
|
||||||
module.exports = authenticate;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const { sequelize } = require('../config/database');
|
|
||||||
const User = require('./User');
|
|
||||||
|
|
||||||
const Block = sequelize.define('Block', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
svg_data: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 'Allgemein',
|
|
||||||
},
|
|
||||||
is_public: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
defaultValue: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Block.belongsTo(User, { foreignKey: 'userId', allowNull: true, onDelete: 'SET NULL' });
|
|
||||||
User.hasMany(Block, { foreignKey: 'userId' });
|
|
||||||
|
|
||||||
module.exports = Block;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const { sequelize } = require('../config/database');
|
|
||||||
const User = require('./User');
|
|
||||||
|
|
||||||
const Drawing = sequelize.define('Drawing', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'Unbenannt',
|
|
||||||
},
|
|
||||||
canvas_json: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
thumbnail: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Drawing.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
|
|
||||||
User.hasMany(Drawing, { foreignKey: 'userId' });
|
|
||||||
|
|
||||||
module.exports = Drawing;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const { sequelize } = require('../config/database');
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
|
|
||||||
const User = sequelize.define('User', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true,
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
unique: true,
|
|
||||||
allowNull: false,
|
|
||||||
validate: { isEmail: true },
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
User.beforeCreate(async (user) => {
|
|
||||||
user.password = await bcrypt.hash(user.password, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
User.prototype.validatePassword = async function (password) {
|
|
||||||
return bcrypt.compare(password, this.password);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = User;
|
|
||||||
Generated
-2031
File diff suppressed because it is too large
Load Diff
+26
-20
@@ -1,25 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "web-cad-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
"main": "index.js",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"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"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"@fastify/cors": "^9.0.0",
|
||||||
"cors": "^2.8.6",
|
"@fastify/static": "^7.0.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"@fastify/websocket": "^10.0.0",
|
||||||
"express": "^5.2.1",
|
"bcrypt": "^6.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"better-sqlite3": "^11.0.0",
|
||||||
"multer": "^2.1.1",
|
"fastify": "^4.28.0",
|
||||||
"passport": "^0.7.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"yjs": "^13.6.0"
|
||||||
"passport-local": "^1.0.0",
|
},
|
||||||
"pg": "^8.21.0",
|
"devDependencies": {
|
||||||
"sequelize": "^6.37.8"
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Dimension Tool",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Fügt ein Bemaßungswerkzeug für horizontale und vertikale Messungen hinzu.",
|
|
||||||
"author": "Agent Zero"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
*{box-sizing:border-box}body{background:#f0f2f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>frontend</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-Bdiu6cTh.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-X7gJsvPn.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const User = require('../models/User');
|
|
||||||
const authenticate = require('../middleware/auth');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_in_production';
|
|
||||||
|
|
||||||
// Register
|
|
||||||
router.post('/register', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
if (!email || !password) {
|
|
||||||
return res.status(400).json({ error: 'Email and password required' });
|
|
||||||
}
|
|
||||||
const existing = await User.findOne({ where: { email } });
|
|
||||||
if (existing) {
|
|
||||||
return res.status(409).json({ error: 'User already exists' });
|
|
||||||
}
|
|
||||||
const user = await User.create({ email, password });
|
|
||||||
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
|
||||||
res.status(201).json({ token, user: { id: user.id, email: user.email } });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Registration failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login
|
|
||||||
router.post('/login', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
if (!email || !password) {
|
|
||||||
return res.status(400).json({ error: 'Email and password required' });
|
|
||||||
}
|
|
||||||
const user = await User.findOne({ where: { email } });
|
|
||||||
if (!user || !(await user.validatePassword(password))) {
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
|
||||||
res.json({ token, user: { id: user.id, email: user.email } });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Login failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get current user
|
|
||||||
router.get('/me', authenticate, async (req, res) => {
|
|
||||||
res.json({ id: req.user.id, email: req.user.email });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const authenticate = require('../middleware/auth');
|
|
||||||
const Block = require('../models/Block');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Public blocks (no auth for listing)
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const blocks = await Block.findAll({
|
|
||||||
where: { is_public: true },
|
|
||||||
attributes: ['id', 'name', 'svg_data', 'category'],
|
|
||||||
});
|
|
||||||
res.json(blocks);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to list blocks' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Protected: create a new block (requires auth)
|
|
||||||
router.post('/', authenticate, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, svg_data, category, is_public } = req.body;
|
|
||||||
if (!name || !svg_data) {
|
|
||||||
return res.status(400).json({ error: 'Name and svg_data required' });
|
|
||||||
}
|
|
||||||
const block = await Block.create({
|
|
||||||
name,
|
|
||||||
svg_data,
|
|
||||||
category: category || 'Allgemein',
|
|
||||||
is_public: is_public !== undefined ? is_public : true,
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
res.status(201).json(block);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to create block' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Protected: delete block (only owner)
|
|
||||||
router.delete('/:id', authenticate, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const block = await Block.findOne({
|
|
||||||
where: { id: req.params.id, userId: req.user.id },
|
|
||||||
});
|
|
||||||
if (!block) {
|
|
||||||
return res.status(404).json({ error: 'Block not found or not authorized' });
|
|
||||||
}
|
|
||||||
await block.destroy();
|
|
||||||
res.json({ message: 'Block deleted' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to delete block' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const authenticate = require('../middleware/auth');
|
|
||||||
const Drawing = require('../models/Drawing');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// All routes require authentication
|
|
||||||
router.use(authenticate);
|
|
||||||
|
|
||||||
// List drawings for current user
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const drawings = await Drawing.findAll({
|
|
||||||
where: { userId: req.user.id },
|
|
||||||
attributes: ['id', 'name', 'updatedAt'],
|
|
||||||
order: [['updatedAt', 'DESC']],
|
|
||||||
});
|
|
||||||
res.json(drawings);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to list drawings' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new drawing
|
|
||||||
router.post('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, canvas_json } = req.body;
|
|
||||||
const drawing = await Drawing.create({
|
|
||||||
name: name || 'Unbenannt',
|
|
||||||
canvas_json: canvas_json || '',
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
res.status(201).json(drawing);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to create drawing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get single drawing
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const drawing = await Drawing.findOne({
|
|
||||||
where: { id: req.params.id, userId: req.user.id },
|
|
||||||
});
|
|
||||||
if (!drawing) {
|
|
||||||
return res.status(404).json({ error: 'Drawing not found' });
|
|
||||||
}
|
|
||||||
res.json(drawing);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to get drawing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update drawing
|
|
||||||
router.put('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const drawing = await Drawing.findOne({
|
|
||||||
where: { id: req.params.id, userId: req.user.id },
|
|
||||||
});
|
|
||||||
if (!drawing) {
|
|
||||||
return res.status(404).json({ error: 'Drawing not found' });
|
|
||||||
}
|
|
||||||
const { name, canvas_json } = req.body;
|
|
||||||
if (name !== undefined) drawing.name = name;
|
|
||||||
if (canvas_json !== undefined) drawing.canvas_json = canvas_json;
|
|
||||||
await drawing.save();
|
|
||||||
res.json(drawing);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to update drawing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete drawing
|
|
||||||
router.delete('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const drawing = await Drawing.findOne({
|
|
||||||
where: { id: req.params.id, userId: req.user.id },
|
|
||||||
});
|
|
||||||
if (!drawing) {
|
|
||||||
return res.status(404).json({ error: 'Drawing not found' });
|
|
||||||
}
|
|
||||||
await drawing.destroy();
|
|
||||||
res.json({ message: 'Drawing deleted' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Failed to delete drawing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const authenticate = require('../middleware/auth');
|
|
||||||
const multer = require('multer');
|
|
||||||
const { parseString } = require('dxf-parser');
|
|
||||||
const router = express.Router();
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
|
||||||
|
|
||||||
// Upload DXF file -> parse and return JSON representation (Fabric.js compatible)
|
|
||||||
router.post('/import', authenticate, upload.single('file'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
const dxfContent = req.file.buffer.toString('utf-8');
|
|
||||||
console.log('DXF file size:', dxfContent.length);
|
|
||||||
const parsed = parseString(dxfContent);
|
|
||||||
// Convert parsed entities to Fabric.js objects (simplified)
|
|
||||||
const fabricObjects = [];
|
|
||||||
if (parsed && parsed.entities) {
|
|
||||||
parsed.entities.forEach(entity => {
|
|
||||||
const type = entity.type;
|
|
||||||
if (type === 'LINE') {
|
|
||||||
fabricObjects.push({
|
|
||||||
type: 'line',
|
|
||||||
x1: entity.start.x, y1: entity.start.y,
|
|
||||||
x2: entity.end.x, y2: entity.end.y,
|
|
||||||
stroke: '#000', strokeWidth: 1,
|
|
||||||
});
|
|
||||||
} else if (type === 'CIRCLE') {
|
|
||||||
fabricObjects.push({
|
|
||||||
type: 'circle',
|
|
||||||
left: entity.center.x - entity.radius,
|
|
||||||
top: entity.center.y - entity.radius,
|
|
||||||
radius: entity.radius,
|
|
||||||
fill: 'transparent', stroke: '#000',
|
|
||||||
});
|
|
||||||
} else if (type === 'ARC') {
|
|
||||||
// simplified arc handling
|
|
||||||
fabricObjects.push({
|
|
||||||
type: 'path',
|
|
||||||
path: `M ${entity.center.x} ${entity.center.y}`, // placeholder
|
|
||||||
stroke: '#000', strokeWidth: 1,
|
|
||||||
});
|
|
||||||
} else if (type === 'POLYLINE' || type === 'LWPOLYLINE') {
|
|
||||||
// convert vertices to polygon
|
|
||||||
if (entity.vertices && entity.vertices.length >= 2) {
|
|
||||||
const points = entity.vertices.map(v => ({ x: v.x, y: v.y }));
|
|
||||||
fabricObjects.push({
|
|
||||||
type: 'polygon',
|
|
||||||
points,
|
|
||||||
stroke: '#000', strokeWidth: 1, fill: 'transparent',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.json({ objects: fabricObjects });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('DXF import error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to parse DXF file: ' + err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export canvas JSON to DXF string
|
|
||||||
router.post('/export', authenticate, (req, res) => {
|
|
||||||
try {
|
|
||||||
const { objects } = req.body;
|
|
||||||
if (!objects || !Array.isArray(objects)) {
|
|
||||||
return res.status(400).json({ error: 'Invalid objects array' });
|
|
||||||
}
|
|
||||||
// Build DXF content
|
|
||||||
let dxf = '0\nSECTION\n2\nHEADER\n0\nENDSEC\n';
|
|
||||||
dxf += '0\nSECTION\n2\nTABLES\n0\nENDSEC\n';
|
|
||||||
dxf += '0\nSECTION\n2\nBLOCKS\n0\nENDSEC\n';
|
|
||||||
dxf += '0\nSECTION\n2\nENTITIES\n';
|
|
||||||
objects.forEach(obj => {
|
|
||||||
if (obj.type === 'line') {
|
|
||||||
dxf += `0\nLINE\n8\n0\n10\n${obj.x1}\n20\n${obj.y1}\n11\n${obj.x2}\n21\n${obj.y2}\n`;
|
|
||||||
} else if (obj.type === 'circle') {
|
|
||||||
const cx = obj.left + obj.radius;
|
|
||||||
const cy = obj.top + obj.radius;
|
|
||||||
dxf += `0\nCIRCLE\n8\n0\n10\n${cx}\n20\n${cy}\n40\n${obj.radius}\n`;
|
|
||||||
} else if (obj.type === 'polygon') {
|
|
||||||
// LWPOLYLINE
|
|
||||||
dxf += `0\nLWPOLYLINE\n8\n0\n90\n${obj.points.length}\n`;
|
|
||||||
obj.points.forEach((pt, i) => {
|
|
||||||
dxf += `10\n${pt.x}\n20\n${pt.y}\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dxf += '0\nENDSEC\n0\nEOF\n';
|
|
||||||
res.setHeader('Content-Type', 'application/dxf');
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename=drawing.dxf');
|
|
||||||
res.send(dxf);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('DXF export error:', err);
|
|
||||||
res.status(500).json({ error: 'Export failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
const PLUGINS_DIR = path.join(__dirname, '..', 'plugins');
|
|
||||||
|
|
||||||
// Ensure plugins directory exists
|
|
||||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
||||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// List all available plugins
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
||||||
return res.json([]);
|
|
||||||
}
|
|
||||||
const plugins = [];
|
|
||||||
const dirs = fs.readdirSync(PLUGINS_DIR);
|
|
||||||
for (const dir of dirs) {
|
|
||||||
const pluginPath = path.join(PLUGINS_DIR, dir);
|
|
||||||
const manifestPath = path.join(pluginPath, 'plugin.json');
|
|
||||||
if (fs.existsSync(manifestPath)) {
|
|
||||||
try {
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
||||||
plugins.push({ id: dir, ...manifest });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Invalid plugin manifest: ${dir}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.json(plugins);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Plugin list error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to list plugins' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve plugin code as JavaScript module
|
|
||||||
router.get('/:id/code', (req, res) => {
|
|
||||||
try {
|
|
||||||
const pluginDir = path.join(PLUGINS_DIR, req.params.id);
|
|
||||||
const codePath = path.join(pluginDir, 'plugin.js');
|
|
||||||
if (!fs.existsSync(codePath)) {
|
|
||||||
return res.status(404).json({ error: 'Plugin code not found' });
|
|
||||||
}
|
|
||||||
res.setHeader('Content-Type', 'application/javascript');
|
|
||||||
res.sendFile(codePath);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Plugin code error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to load plugin code' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
const { sequelize, connectDB } = require('./config/database');
|
|
||||||
const Block = require('./models/Block');
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
|
|
||||||
const eventBlocks = [
|
|
||||||
{
|
|
||||||
name: 'Stuhl (Standard)',
|
|
||||||
category: 'Bestuhlung',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 40 40">
|
|
||||||
<rect x="5" y="5" width="30" height="30" rx="2" fill="#4a90d9" stroke="#2c5f8a" stroke-width="2"/>
|
|
||||||
<rect x="10" y="28" width="20" height="4" rx="1" fill="#d4e6f1"/>
|
|
||||||
<circle cx="20" cy="18" r="8" fill="#d4e6f1" stroke="#2c5f8a" stroke-width="1.5"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tisch (Rechteck 180x80)',
|
|
||||||
category: 'Möbel',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 180 80">
|
|
||||||
<rect x="0" y="0" width="180" height="80" rx="4" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
|
|
||||||
<rect x="5" y="5" width="170" height="70" rx="2" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tisch (Rund Ø120)',
|
|
||||||
category: 'Möbel',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 120 120">
|
|
||||||
<circle cx="60" cy="60" r="58" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
|
|
||||||
<circle cx="60" cy="60" r="50" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Bühne (Modular 200x100)',
|
|
||||||
category: 'Bühne',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 200 100">
|
|
||||||
<rect x="0" y="0" width="200" height="100" fill="#8b7355" stroke="#6b5335" stroke-width="2"/>
|
|
||||||
<line x1="0" y1="25" x2="200" y2="25" stroke="#a08060" stroke-width="1"/>
|
|
||||||
<line x1="0" y1="50" x2="200" y2="50" stroke="#a08060" stroke-width="1"/>
|
|
||||||
<line x1="0" y1="75" x2="200" y2="75" stroke="#a08060" stroke-width="1"/>
|
|
||||||
<line x1="50" y1="0" x2="50" y2="100" stroke="#a08060" stroke-width="1"/>
|
|
||||||
<line x1="100" y1="0" x2="100" y2="100" stroke="#a08060" stroke-width="1"/>
|
|
||||||
<line x1="150" y1="0" x2="150" y2="100" stroke="#a08060" stroke-width="1"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stehtisch',
|
|
||||||
category: 'Möbel',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 60 60">
|
|
||||||
<circle cx="30" cy="30" r="28" fill="#e8d5b7" stroke="#8b7355" stroke-width="2"/>
|
|
||||||
<circle cx="30" cy="30" r="24" fill="#f5ead0" stroke="#c4a882" stroke-width="1"/>
|
|
||||||
<circle cx="30" cy="30" r="5" fill="#8b7355"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Barhocker',
|
|
||||||
category: 'Bestuhlung',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 30 30">
|
|
||||||
<circle cx="15" cy="12" r="10" fill="#4a90d9" stroke="#2c5f8a" stroke-width="2"/>
|
|
||||||
<rect x="12" y="20" width="6" height="8" fill="#7f8c8d"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mischpult',
|
|
||||||
category: 'Technik',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 120 60">
|
|
||||||
<rect x="0" y="0" width="120" height="60" rx="3" fill="#2c3e50" stroke="#1a252f" stroke-width="2"/>
|
|
||||||
<rect x="5" y="5" width="110" height="15" fill="#34495e" stroke="#2c3e50" stroke-width="1"/>
|
|
||||||
<circle cx="20" cy="40" r="5" fill="#e74c3c"/>
|
|
||||||
<circle cx="40" cy="40" r="5" fill="#e67e22"/>
|
|
||||||
<circle cx="60" cy="40" r="5" fill="#f1c40f"/>
|
|
||||||
<circle cx="80" cy="40" r="5" fill="#3498db"/>
|
|
||||||
<circle cx="100" cy="40" r="5" fill="#2ecc71"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Lautsprecher',
|
|
||||||
category: 'Technik',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 40 60">
|
|
||||||
<rect x="0" y="0" width="40" height="60" rx="3" fill="#34495e" stroke="#1a252f" stroke-width="2"/>
|
|
||||||
<rect x="8" y="8" width="24" height="25" rx="12" fill="#1a252f"/>
|
|
||||||
<circle cx="20" cy="20" r="6" fill="#7f8c8d"/>
|
|
||||||
<rect x="8" y="40" width="24" height="5" rx="2" fill="#1a252f"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Trennwand',
|
|
||||||
category: 'Raum',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 10 100">
|
|
||||||
<rect x="0" y="0" width="10" height="100" fill="#bdc3c7" stroke="#7f8c8d" stroke-width="1"/>
|
|
||||||
<line x1="2" y1="0" x2="2" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
|
|
||||||
<line x1="5" y1="0" x2="5" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
|
|
||||||
<line x1="8" y1="0" x2="8" y2="100" stroke="#95a5a6" stroke-width="0.5"/>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Notausgang-Schild',
|
|
||||||
category: 'Sicherheit',
|
|
||||||
is_public: true,
|
|
||||||
svg_data: `<svg viewBox="0 0 40 20">
|
|
||||||
<rect x="0" y="0" width="40" height="20" rx="2" fill="#27ae60" stroke="#1e8449" stroke-width="1"/>
|
|
||||||
<polygon points="8,5 3,10 8,15 7,15 2,10 7,5" fill="white"/>
|
|
||||||
<text x="20" y="15" font-size="8" fill="white" text-anchor="middle" font-family="Arial">EXIT</text>
|
|
||||||
</svg>`
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function seed() {
|
|
||||||
try {
|
|
||||||
await connectDB();
|
|
||||||
// Use raw SQL to create user, bcrypt hashing via sequelize might fail, so we do raw
|
|
||||||
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
|
||||||
await sequelize.query(`
|
|
||||||
INSERT INTO "Users" ("email", "password", "createdAt", "updatedAt")
|
|
||||||
VALUES (:email, :password, datetime('now'), datetime('now'))
|
|
||||||
ON CONFLICT ("email") DO NOTHING;
|
|
||||||
`, {
|
|
||||||
replacements: { email: 'admin@cad.local', password: hashedPassword },
|
|
||||||
type: sequelize.QueryTypes.INSERT
|
|
||||||
});
|
|
||||||
console.log('Admin user ensured.');
|
|
||||||
// Clear existing blocks
|
|
||||||
await Block.destroy({ where: {} });
|
|
||||||
console.log('Existing blocks cleared.');
|
|
||||||
for (const block of eventBlocks) {
|
|
||||||
await Block.create(block);
|
|
||||||
}
|
|
||||||
console.log(`${eventBlocks.length} blocks created.`);
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Seed failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seed();
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
|
||||||
const passport = require('passport');
|
|
||||||
require('./config/passport'); // Load JWT strategy
|
|
||||||
const User = require('./models/User');
|
|
||||||
const Block = require('./models/Block');
|
|
||||||
const { sequelize, connectDB } = require('./config/database');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 5000;
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '50mb' }));
|
|
||||||
app.use(passport.initialize());
|
|
||||||
|
|
||||||
// Serve static frontend
|
|
||||||
app.use(express.static('public'));
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
|
||||||
app.use('/api/drawings', require('./routes/drawings'));
|
|
||||||
app.use('/api/blocks', require('./routes/blocks'));
|
|
||||||
app.use('/api/dxf', require('./routes/dxf'));
|
|
||||||
app.use('/api/plugins', require('./routes/plugins'));
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
||||||
|
|
||||||
// SPA fallback – serve index.html for all non-API routes
|
|
||||||
app.get(/^(?!\/api\/).*/, (req, res) => {
|
|
||||||
res.sendFile('public/index.html', { root: __dirname });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect DB and start
|
|
||||||
connectDB().then(() => sequelize.sync({ force: false })).then(async () => {
|
|
||||||
// Seed admin user if not exists
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const existing = await User.findOne({ where: { email: 'admin@cad.local' } });
|
|
||||||
if (!existing) {
|
|
||||||
await User.create({ email: 'admin@cad.local', password: 'admin123' });
|
|
||||||
console.log('Admin user created: admin@cad.local / admin123');
|
|
||||||
}
|
|
||||||
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Unable to start server:', err);
|
|
||||||
});
|
|
||||||
@@ -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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
+6
-1
@@ -4,12 +4,17 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- JWT_SECRET=your-secret-key-change-me
|
- JWT_SECRET=your-secret-key-change-me
|
||||||
volumes:
|
volumes:
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Web CAD</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+19
-20
@@ -1,25 +1,24 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
resolver 127.0.0.11 valid=30s;
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Serve static frontend
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API requests to backend
|
location /api/ {
|
||||||
location /api/ {
|
set $backend http://backend:3001;
|
||||||
proxy_pass http://backend:5000;
|
proxy_pass $backend;
|
||||||
proxy_http_version 1.1;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header Connection 'upgrade';
|
}
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
location /ws {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
set $backend http://backend:3001;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_pass $backend;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_http_version 1.1;
|
||||||
}
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5080
-420
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:5000',
|
'/api': 'http://localhost:3001',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
|
|||||||
Reference in New Issue
Block a user