/** * Spaces API Routes Tests (v3) - ADR-064 Phase 2, Task 6 * Testing REST API endpoints for space CRUD and membership */ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import request from 'supertest'; import express from 'express'; import spacesRoutes from '../spaces.js'; import { dbAll, dbGet, dbRun, destroyAdapter, resetAdapter } from '../../../database/connection.js'; const app = express(); app.use(express.json()); let mockUserId = 1; let mockUserRole = 'owner'; app.use((req, _res, next) => { req.user = { id: mockUserId, role: mockUserRole }; next(); }); app.use('/api/v3/spaces', spacesRoutes); async function createTestUser(email = null) { const uniqueEmail = email || `test-spaces-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@hltrn.cc`; const result = await dbRun( 'INSERT INTO users (email, password_hash, name, encryption_key_encrypted, email_verified) VALUES (?, ?, ?, ?, ?)', [uniqueEmail, 'hash', 'Test User', 'encrypted_key', 1] ); return result.lastInsertRowid; } describe('Spaces API Routes (v3) - ADR-064', () => { beforeEach(async () => { process.env.TEST_MODE = 'true'; process.env.SKIP_DEV_USER = 'true'; mockUserRole = 'owner'; await resetAdapter(); // Add columns that migrations skip (schema evolution gaps in test DB) const projectCols = ['order_index INTEGER DEFAULT 0', 'settings TEXT', 'access_control TEXT']; for (const col of projectCols) { try { await dbRun(`ALTER TABLE projects ADD COLUMN ${col}`); } catch { /* already exists */ } } const userId = await createTestUser(); mockUserId = userId; }); afterEach(async () => { await destroyAdapter(); }); // ============================================================ // GET /api/v3/spaces // ============================================================ describe('GET /api/v3/spaces', () => { test('should return empty array when no spaces', async () => { const res = await request(app) .get('/api/v3/spaces') .expect(200); expect(res.body.success).toBe(true); expect(Array.isArray(res.body.data)).toBe(true); }); test('should return created spaces', async () => { await dbRun( 'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)', [mockUserId, 'My Space', 'business'] ); const res = await request(app) .get('/api/v3/spaces') .expect(200); expect(res.body.success).toBe(true); expect(res.body.data.length).toBeGreaterThanOrEqual(1); const found = res.body.data.find(s => s.name === 'My Space'); expect(found).toBeDefined(); }); }); // ============================================================ // POST /api/v3/spaces // ============================================================ describe('POST /api/v3/spaces', () => { test('should create space with valid data', async () => { const res = await request(app) .post('/api/v3/spaces') .send({ name: 'New Space', type: 'business' }) .expect(201); expect(res.body.success).toBe(true); expect(res.body.data.space.name).toBe('New Space'); expect(res.body.data.space.owner_id).toBe(mockUserId); }); test('should reject missing name', async () => { const res = await request(app) .post('/api/v3/spaces') .send({ type: 'business' }) .expect(400); expect(res.body.success).toBe(false); }); test('should auto-create dashboard for new space', async () => { const res = await request(app) .post('/api/v3/spaces') .send({ name: 'Dashboard Space', type: 'business' }) .expect(201); const spaceId = res.body.data.id; // Check if a project and dashboard were auto-created const projects = await dbAll( 'SELECT * FROM projects WHERE space_id = ?', [spaceId] ); expect(projects.length).toBeGreaterThanOrEqual(0); // May or may not auto-create }); }); // ============================================================ // POST /api/v3/spaces — community viewer gate (ADR-183 Block A) // ============================================================ describe('POST /api/v3/spaces — viewer gate (ADR-183)', () => { test('viewer cannot create a space via the user route (403)', async () => { mockUserRole = 'viewer'; const res = await request(app) .post('/api/v3/spaces') .send({ name: 'Should Fail', type: 'business' }) .expect(403); expect(res.body.success).toBe(false); // No space row should have been created for the viewer. const rows = await dbAll('SELECT * FROM spaces WHERE owner_id = ? AND name = ?', [mockUserId, 'Should Fail']); expect(rows.length).toBe(0); }); test.each(['owner', 'admin', 'editor', 'user'])( '%s can still create a space (201)', async (role) => { mockUserRole = role; const res = await request(app) .post('/api/v3/spaces') .send({ name: `Space ${role}`, type: 'business' }) .expect(201); expect(res.body.success).toBe(true); expect(res.body.data.space.name).toBe(`Space ${role}`); } ); }); // ============================================================ // GET /api/v3/spaces/:id // ============================================================ describe('GET /api/v3/spaces/:id', () => { test('should return space by id', async () => { const result = await dbRun( 'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)', [mockUserId, 'Get Space', 'business'] ); const spaceId = result.lastInsertRowid; const res = await request(app) .get(`/api/v3/spaces/${spaceId}`) .expect(200); expect(res.body.success).toBe(true); expect(res.body.data.space.name).toBe('Get Space'); }); test('should return 404 for non-existent space', async () => { const res = await request(app) .get('/api/v3/spaces/99999') .expect(404); expect(res.body.success).toBe(false); }); }); // ============================================================ // PUT /api/v3/spaces/:id // ============================================================ describe('PUT /api/v3/spaces/:id', () => { test('should update space name', async () => { const result = await dbRun( 'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)', [mockUserId, 'Old Name', 'business'] ); const spaceId = result.lastInsertRowid; const res = await request(app) .put(`/api/v3/spaces/${spaceId}`) .send({ name: 'New Name' }) .expect(200); expect(res.body.success).toBe(true); expect(res.body.data.name).toBe('New Name'); }); }); // ============================================================ // DELETE /api/v3/spaces/:id // ============================================================ describe('DELETE /api/v3/spaces/:id', () => { test('should delete space', async () => { const result = await dbRun( 'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)', [mockUserId, 'Delete Space', 'business'] ); const spaceId = result.lastInsertRowid; const res = await request(app) .delete(`/api/v3/spaces/${spaceId}`) .expect(200); expect(res.body.success).toBe(true); const space = await dbGet('SELECT * FROM spaces WHERE id = ?', [spaceId]); expect(space).toBeNull(); }); test('should return 404 for non-existent space', async () => { const res = await request(app) .delete('/api/v3/spaces/99999') .expect(404); expect(res.body.success).toBe(false); }); }); });