Refresh of the open-core distribution from the private tree. Included since the previous snapshot: - Mail module (ADR-158/159/160/169): composer, labels, scheduling, attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083 - Crawler-readable SSR for /blog and public spaces (ADR-190): blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages - Registration policy + referral/promo settings (ADR-183/188) - Message translation + language detection (ADR-185) - Reddit connector for the agent-tool surface Excised from the public distribution (unchanged policy): infrastructure topology and host config, internal ops scripts, DB cleanup snapshots, business documents, throwaway debug scripts, and two private product lines (SC-SIM simulator, personal one-off tools). Real host addresses are replaced with placeholders; credential-shaped literals are redacted. Frontend build verified green on this tree.
233 lines
7.7 KiB
JavaScript
233 lines
7.7 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|