This commit is contained in:
@@ -102,7 +102,7 @@ export async function createServer(opts: ServerOptions) {
|
|||||||
registerShareRoutes(fastify, opts.db, authService);
|
registerShareRoutes(fastify, opts.db, authService);
|
||||||
|
|
||||||
// Yjs collaboration WebSocket
|
// Yjs collaboration WebSocket
|
||||||
registerYjsWebSocket(fastify);
|
registerYjsWebSocket(fastify, authService);
|
||||||
|
|
||||||
return fastify;
|
return fastify;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as Y from 'yjs';
|
|||||||
import { LeveldbPersistence } from 'y-leveldb';
|
import { LeveldbPersistence } from 'y-leveldb';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { WebSocket } from '@fastify/websocket';
|
import type { WebSocket } from '@fastify/websocket';
|
||||||
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
|
||||||
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ let persistenceReady: Promise<void> | null = null;
|
|||||||
export async function initYjsPersistence(): Promise<void> {
|
export async function initYjsPersistence(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
||||||
|
// Set persistenceReady so getOrCreateDoc can await it
|
||||||
|
persistenceReady = (async () => {
|
||||||
|
// Wait for LevelDB to be ready
|
||||||
|
await (persistence as any).getAllDocNames();
|
||||||
|
})();
|
||||||
|
await persistenceReady;
|
||||||
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Yjs persistence failed to init (non-fatal):', err);
|
console.warn('Yjs persistence failed to init (non-fatal):', err);
|
||||||
@@ -80,7 +87,7 @@ function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerYjsWebSocket(fastify: FastifyInstance): void {
|
export function registerYjsWebSocket(fastify: FastifyInstance, authService: AuthService): void {
|
||||||
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
|
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
|
||||||
const docName = request.params.docName as string;
|
const docName = request.params.docName as string;
|
||||||
if (!docName) {
|
if (!docName) {
|
||||||
@@ -88,6 +95,18 @@ export function registerYjsWebSocket(fastify: FastifyInstance): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #2: Authenticate WebSocket connection via query param token
|
||||||
|
const token = request.query.token as string;
|
||||||
|
if (!token) {
|
||||||
|
socket.close(4001, 'Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = authService.getUserFromSession(token);
|
||||||
|
if (!user) {
|
||||||
|
socket.close(4001, 'Invalid or expired session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const doc = await getOrCreateDoc(docName);
|
const doc = await getOrCreateDoc(docName);
|
||||||
|
|
||||||
// Track connection
|
// Track connection
|
||||||
@@ -113,16 +132,14 @@ export function registerYjsWebSocket(fastify: FastifyInstance): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up on disconnect
|
// Clean up on disconnect (Issue #5: keep doc in memory for reconnection)
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
const conns = connections.get(docName);
|
const conns = connections.get(docName);
|
||||||
if (conns) {
|
if (conns) {
|
||||||
conns.delete(socket);
|
conns.delete(socket);
|
||||||
if (conns.size === 0) {
|
if (conns.size === 0) {
|
||||||
connections.delete(docName);
|
connections.delete(docName);
|
||||||
// Optionally keep doc in memory for a while
|
// Keep doc in memory for reconnection (persistence handles durability)
|
||||||
// For now, remove it to free memory
|
|
||||||
docs.delete(docName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
userName: user?.name || 'Gast',
|
userName: user?.name || 'Gast',
|
||||||
userColor: '#3498db',
|
userColor: '#3498db',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
token: token || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Project
|
// Project
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class WebSocketProvider {
|
|||||||
private reconnectDelay = 1000;
|
private reconnectDelay = 1000;
|
||||||
private maxReconnectDelay = 30000;
|
private maxReconnectDelay = 30000;
|
||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
|
private authToken: string | undefined;
|
||||||
|
|
||||||
constructor(opts: WebSocketProviderOptions) {
|
constructor(opts: WebSocketProviderOptions) {
|
||||||
this.url = opts.url;
|
this.url = opts.url;
|
||||||
@@ -36,15 +37,17 @@ export class WebSocketProvider {
|
|||||||
this.onSync = opts.onSync;
|
this.onSync = opts.onSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(): void {
|
connect(token?: string): void {
|
||||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.authToken = token;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.setStatus('connecting');
|
this.setStatus('connecting');
|
||||||
|
|
||||||
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
|
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
|
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}${tokenParam}`;
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(fullUrl);
|
this.ws = new WebSocket(fullUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,7 +133,7 @@ export class WebSocketProvider {
|
|||||||
if (this.reconnectTimer) return;
|
if (this.reconnectTimer) return;
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.connect();
|
this.connect(this.authToken);
|
||||||
}, this.reconnectDelay);
|
}, this.reconnectDelay);
|
||||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface UseYjsBindingOptions {
|
|||||||
userName: string;
|
userName: string;
|
||||||
userColor: string;
|
userColor: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseYjsBindingResult {
|
export interface UseYjsBindingResult {
|
||||||
@@ -44,7 +45,7 @@ export interface UseYjsBindingResult {
|
|||||||
const DEFAULT_WS_URL = `wss://${window.location.host}`;
|
const DEFAULT_WS_URL = `wss://${window.location.host}`;
|
||||||
|
|
||||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true } = opts;
|
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
||||||
|
|
||||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||||
const providerRef = useRef<WebSocketProvider | null>(null);
|
const providerRef = useRef<WebSocketProvider | null>(null);
|
||||||
@@ -92,7 +93,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
const unbindLocal = provider.bindLocalUpdates();
|
const unbindLocal = provider.bindLocalUpdates();
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
provider.connect();
|
provider.connect(token);
|
||||||
syncState();
|
syncState();
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -107,7 +108,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
providerRef.current = null;
|
providerRef.current = null;
|
||||||
awarenessRef.current = null;
|
awarenessRef.current = null;
|
||||||
};
|
};
|
||||||
}, [docName, wsUrl, userId, userName, userColor, enabled]);
|
}, [docName, wsUrl, userId, userName, userColor, enabled, token]);
|
||||||
|
|
||||||
// Mutators
|
// Mutators
|
||||||
const setElement = useCallback((el: CADElement) => {
|
const setElement = useCallback((el: CADElement) => {
|
||||||
|
|||||||
+65
-147
@@ -1,170 +1,88 @@
|
|||||||
# Test Report – Phase 21: Vitest Test-Setup für web-cad-neu
|
# Test Report – Issues #2, #4, #5 Fixes
|
||||||
|
|
||||||
**Datum:** 2026-06-26
|
**Date**: 2026-06-29
|
||||||
**Status:** ✅ Alle Tests grün
|
**Task**: Fix WebSocket auth (#2), persistenceReady (#4), doc retention (#5)
|
||||||
|
|
||||||
## Backend Tests (31 Tests, 3 Files)
|
## Build Results
|
||||||
|
|
||||||
### Test-Command
|
### Backend TypeScript Check
|
||||||
```
|
```
|
||||||
cd /a0/usr/workdir/web-cad-neu/backend && npm test
|
cd /a0/usr/workdir/web-cad-neu/backend && npx tsc --noEmit
|
||||||
|
→ Exit 0 (no errors)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ergebnis
|
### Backend Build
|
||||||
```
|
```
|
||||||
Test Files 3 passed (3)
|
npm run build
|
||||||
Tests 31 passed (31)
|
→ Exit 0 (tsc + cp schema.sql)
|
||||||
Duration 2.92s
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test-Dateien
|
### Frontend TypeScript Check
|
||||||
|
|
||||||
#### tests/health.test.ts (2 Tests)
|
|
||||||
- ✅ GET /api/health should return ok status
|
|
||||||
- ✅ GET /api/health should return valid ISO timestamp
|
|
||||||
|
|
||||||
#### tests/auth.test.ts (18 Tests)
|
|
||||||
- ✅ POST /api/auth/register (3 tests)
|
|
||||||
- Register new user with 201
|
|
||||||
- Reject duplicate registration with 409
|
|
||||||
- Reject missing fields with 400
|
|
||||||
- ✅ POST /api/auth/login (4 tests)
|
|
||||||
- Login with correct credentials
|
|
||||||
- Reject wrong password with 401
|
|
||||||
- Reject non-existent email with 401
|
|
||||||
- Reject missing fields with 400
|
|
||||||
- ✅ GET /api/auth/me (3 tests)
|
|
||||||
- Return current user profile with valid token
|
|
||||||
- Reject without token with 401
|
|
||||||
- Reject invalid token with 401
|
|
||||||
- ✅ PATCH /api/auth/password (6 tests)
|
|
||||||
- Change password with correct old password
|
|
||||||
- Allow login with new password
|
|
||||||
- Reject old password after change
|
|
||||||
- Reject password change with wrong old password
|
|
||||||
- Reject password change without auth
|
|
||||||
- Reject missing password fields
|
|
||||||
- ✅ POST /api/auth/logout (2 tests)
|
|
||||||
- Logout and invalidate token
|
|
||||||
- Return 204 even without token
|
|
||||||
|
|
||||||
#### tests/projects.test.ts (11 Tests)
|
|
||||||
- ✅ POST /api/projects (3 tests)
|
|
||||||
- Create project with 201
|
|
||||||
- Reject without name with 400
|
|
||||||
- Create with default values
|
|
||||||
- ✅ GET /api/projects (1 test)
|
|
||||||
- List all projects
|
|
||||||
- ✅ GET /api/projects/:id (2 tests)
|
|
||||||
- Get single project
|
|
||||||
- Return 404 for non-existent
|
|
||||||
- ✅ PATCH /api/projects/:id (3 tests)
|
|
||||||
- Update project name
|
|
||||||
- Update project description
|
|
||||||
- Return 404 for non-existent update
|
|
||||||
- ✅ DELETE /api/projects/:id (2 tests)
|
|
||||||
- Delete a project
|
|
||||||
- Return 404 for non-existent delete
|
|
||||||
|
|
||||||
## Frontend Tests (84 Tests, 3 Files)
|
|
||||||
|
|
||||||
### Test-Command
|
|
||||||
```
|
```
|
||||||
cd /a0/usr/workdir/web-cad-neu/frontend && npm test
|
cd /a0/usr/workdir/web-cad-neu/frontend && npx tsc --noEmit
|
||||||
|
→ Exit 0 (no errors)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ergebnis
|
### Frontend Build
|
||||||
```
|
```
|
||||||
Test Files 3 passed (3)
|
npm run build
|
||||||
Tests 84 passed (84)
|
→ Exit 0 (tsc + vite build, 341 modules transformed)
|
||||||
Duration 2.40s
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test-Dateien
|
## Smoke Tests
|
||||||
|
|
||||||
#### tests/SpatialIndex.test.ts (13 Tests)
|
### Server Startup
|
||||||
- ✅ insert & search (4 tests)
|
```
|
||||||
- Find inserted element within bounding box
|
PORT=8082 node dist/index.js
|
||||||
- Not find element outside viewport
|
→ Yjs persistence initialized at /tmp/yjs-documents
|
||||||
- Find multiple elements within viewport
|
→ Server listening at http://0.0.0.0:8082
|
||||||
- Handle elements at origin
|
→ Web CAD Backend running on http://0.0.0.0:8082
|
||||||
- ✅ bulkInsert (2 tests)
|
```
|
||||||
- Bulk insert and find all elements
|
|
||||||
- Work with empty array
|
|
||||||
- ✅ remove (2 tests)
|
|
||||||
- Remove element so it's no longer found
|
|
||||||
- Remove only specified element
|
|
||||||
- ✅ clear (1 test)
|
|
||||||
- Clear all elements from index
|
|
||||||
- ✅ edge cases (4 tests)
|
|
||||||
- Handle overlapping bounding boxes
|
|
||||||
- Handle zero-size elements
|
|
||||||
- Handle negative coordinates
|
|
||||||
- Search with large viewport
|
|
||||||
|
|
||||||
#### tests/LayerManager.test.ts (39 Tests)
|
### Health Endpoint (Test #6)
|
||||||
- ✅ addLayer & getLayer (4 tests)
|
```
|
||||||
- ✅ removeLayer (3 tests)
|
curl http://localhost:8082/api/health
|
||||||
- ✅ getLayers (2 tests)
|
→ HTTP 200: {"status":"ok","timestamp":"2026-06-29T21:28:52.696Z"}
|
||||||
- ✅ getVisibleLayers (1 test)
|
```
|
||||||
- ✅ setActiveLayer & getActiveLayer (2 tests)
|
|
||||||
- ✅ toggleVisibility (1 test)
|
|
||||||
- ✅ toggleLock (1 test)
|
|
||||||
- ✅ clear (1 test)
|
|
||||||
- ✅ renameLayer (2 tests)
|
|
||||||
- ✅ updateLayer (1 test)
|
|
||||||
- ✅ setParent (3 tests)
|
|
||||||
- ✅ getChildLayers & getLayerTree (3 tests)
|
|
||||||
- ✅ moveLayer (1 test)
|
|
||||||
- ✅ duplicateLayer (2 tests)
|
|
||||||
- ✅ filterLayers (5 tests)
|
|
||||||
- ✅ getDescendantIds (2 tests)
|
|
||||||
- ✅ isLocked (3 tests)
|
|
||||||
- ✅ getLayerColor (2 tests)
|
|
||||||
|
|
||||||
#### tests/HistoryManager.test.ts (32 Tests)
|
### WS Without Token → Rejected (Test #4)
|
||||||
- ✅ initialize (2 tests)
|
```
|
||||||
- ✅ pushSnapshot (3 tests)
|
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc')
|
||||||
- ✅ undo (4 tests)
|
→ Connection opened (handshake)
|
||||||
- ✅ redo (4 tests)
|
→ Closed: code=4001, reason=Authentication required
|
||||||
- ✅ canUndo & canRedo (4 tests)
|
```
|
||||||
- ✅ getHistory (3 tests)
|
|
||||||
- ✅ jumpTo (4 tests)
|
|
||||||
- ✅ clear (1 test)
|
|
||||||
- ✅ subscribe (2 tests)
|
|
||||||
- ✅ maxStackSize (2 tests)
|
|
||||||
- ✅ getUndoCount & getRedoCount (1 test)
|
|
||||||
- ✅ snapshot data integrity (2 tests)
|
|
||||||
|
|
||||||
## Smoke-Test Beschreibung
|
### WS With Invalid Token → Rejected (Test #5)
|
||||||
|
```
|
||||||
|
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=invalid-token')
|
||||||
|
→ Connection opened (handshake)
|
||||||
|
→ Closed: code=4001, reason=Invalid or expired session
|
||||||
|
```
|
||||||
|
|
||||||
### Backend
|
### WS With Valid Token → Accepted (Test #5)
|
||||||
- In-memory SQLite (`:memory:`) wird via `SqliteAdapter` initialisiert
|
```
|
||||||
- Fastify `app.inject()` simuliert HTTP-Requests ohne echte Netzwerkverbindung
|
python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=8cf8a5ed-...')
|
||||||
- Auth-Flow vollständig getestet: Register → Login → Me → Password Change → Logout
|
→ Connection opened (handshake)
|
||||||
- Project CRUD vollständig getestet: Create → List → Get → Update → Delete
|
→ Connected! Received 30 bytes (initial state sync)
|
||||||
- Health Endpoint gibt `{ status: 'ok', timestamp: ISO-String }` zurück
|
```
|
||||||
|
|
||||||
### Frontend
|
### Login/Register Still Works (Test #7)
|
||||||
- SpatialIndex: rbush-basierte räumliche Suche mit insert/search/remove/clear/bulkInsert
|
```
|
||||||
- LayerManager: Layer-Verwaltung mit add/remove/toggle/tree/filter/duplicate
|
curl -X POST /api/auth/register -d {"email":"smoketest@test.de","password":"test123","name":"Smoke Test"}
|
||||||
- HistoryManager: Undo/Redo-Stack mit initialize/push/undo/redo/jumpTo/subscribe/maxStackSize
|
→ 200: {"user":{"id":"user-1782768734336",...},"session":{"token":"8cf8a5ed-..."}}
|
||||||
- jsdom environment für DOM-abhängige Tests aktiviert
|
```
|
||||||
|
|
||||||
## Coverage
|
## Summary
|
||||||
|
|
||||||
### Backend
|
| Test | Result |
|
||||||
- Auth CRUD: Register, Login, Logout, Me, Password-Change → alle Endpoints getestet
|
|------|--------|
|
||||||
- Project CRUD: List, Get, Create, Update, Delete → alle Endpoints getestet
|
| Backend tsc --noEmit | ✅ Pass |
|
||||||
- Health: GET /api/health → getestet
|
| Frontend tsc --noEmit | ✅ Pass |
|
||||||
|
| Backend build | ✅ Pass |
|
||||||
### Frontend
|
| Frontend build | ✅ Pass |
|
||||||
- SpatialIndex: insert, bulkInsert, search, remove, clear → alle öffentlichen Methoden
|
| Server starts | ✅ Pass |
|
||||||
- LayerManager: alle 18 öffentlichen Methoden getestet
|
| Health endpoint 200 | ✅ Pass |
|
||||||
- HistoryManager: initialize, pushSnapshot, undo, redo, canUndo, canRedo, getHistory, jumpTo, clear, subscribe, getUndoCount, getRedoCount → alle öffentlichen Methoden
|
| WS no token → 4001 | ✅ Pass |
|
||||||
|
| WS invalid token → 4001 | ✅ Pass |
|
||||||
## Verbotene Patterns Check
|
| WS valid token → accepted | ✅ Pass |
|
||||||
- ❌ Keine echten Netzwerk-Verbindungen → ✅ Nur `app.inject()`
|
| Register/login works | ✅ Pass |
|
||||||
- ❌ Keine echten WebSocket-Verbindungen → ✅ Keine WebSocket-Tests
|
|
||||||
- ❌ Keine Datei-Schreiboperationen auf echte DB-Files → ✅ Nur `:memory:`
|
|
||||||
- ❌ Keine Mock-Libraries außer vitest built-in `vi` → ✅ Nur vitest built-ins
|
|
||||||
|
|||||||
Reference in New Issue
Block a user