From 32e8edfb664eaceae3939c3bf90f0b7e9e862c4a Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 16:07:03 +0200 Subject: [PATCH] feat(memoro/server): add comprehensive API route tests 183 tests across 10 files covering all server endpoints: - Health, pricing, 404 handling - Memo CRUD (create, append, retry, combine, Q&A) - Credits (balance, check, consume) - Settings (profile, memoro settings, data usage) - Spaces (CRUD, invites, link/unlink memos) - Meetings (bots, recordings) - Internal callbacks (transcription, batch metadata) - Cleanup (auth, run, manual) - Credit utility (calcTranscriptionCost, COSTS) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/server/src/lib/credits.test.ts | 55 +++ .../apps/server/src/routes/cleanup.test.ts | 120 ++++++ .../apps/server/src/routes/credits.test.ts | 143 +++++++ .../apps/server/src/routes/health.test.ts | 41 ++ .../apps/server/src/routes/internal.test.ts | 264 ++++++++++++ .../apps/server/src/routes/meetings.test.ts | 215 ++++++++++ .../apps/server/src/routes/memos.test.ts | 330 +++++++++++++++ .../apps/server/src/routes/settings.test.ts | 214 ++++++++++ .../apps/server/src/routes/spaces.test.ts | 389 ++++++++++++++++++ apps/memoro/apps/server/vitest.config.ts | 2 - 10 files changed, 1771 insertions(+), 2 deletions(-) create mode 100644 apps/memoro/apps/server/src/lib/credits.test.ts create mode 100644 apps/memoro/apps/server/src/routes/cleanup.test.ts create mode 100644 apps/memoro/apps/server/src/routes/credits.test.ts create mode 100644 apps/memoro/apps/server/src/routes/health.test.ts create mode 100644 apps/memoro/apps/server/src/routes/internal.test.ts create mode 100644 apps/memoro/apps/server/src/routes/meetings.test.ts create mode 100644 apps/memoro/apps/server/src/routes/memos.test.ts create mode 100644 apps/memoro/apps/server/src/routes/settings.test.ts create mode 100644 apps/memoro/apps/server/src/routes/spaces.test.ts diff --git a/apps/memoro/apps/server/src/lib/credits.test.ts b/apps/memoro/apps/server/src/lib/credits.test.ts new file mode 100644 index 000000000..14d837486 --- /dev/null +++ b/apps/memoro/apps/server/src/lib/credits.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for credit utility functions. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@manacore/shared-hono', () => ({ + validateCredits: vi.fn(), + consumeCredits: vi.fn(), +})); + +import { calcTranscriptionCost, COSTS } from './credits'; + +describe('COSTS', () => { + it('has expected cost constants', () => { + expect(COSTS.TRANSCRIPTION_PER_MINUTE).toBe(2); + expect(COSTS.HEADLINE_GENERATION).toBe(10); + expect(COSTS.MEMORY_CREATION).toBe(10); + expect(COSTS.BLUEPRINT_PROCESSING).toBe(5); + expect(COSTS.QUESTION_MEMO).toBe(5); + expect(COSTS.MEMO_COMBINE).toBe(5); + expect(COSTS.MEETING_RECORDING_PER_MINUTE).toBe(2); + }); +}); + +describe('calcTranscriptionCost', () => { + it('calculates cost for 60 seconds (1 min)', () => { + expect(calcTranscriptionCost(60)).toBe(2); + }); + + it('calculates cost for 120 seconds (2 min)', () => { + expect(calcTranscriptionCost(120)).toBe(4); + }); + + it('calculates cost for 90 seconds (1.5 min) — rounds up', () => { + expect(calcTranscriptionCost(90)).toBe(3); + }); + + it('returns minimum 2 for very short recordings', () => { + expect(calcTranscriptionCost(1)).toBe(2); + expect(calcTranscriptionCost(10)).toBe(2); + expect(calcTranscriptionCost(30)).toBe(2); + }); + + it('returns minimum 2 for zero duration', () => { + expect(calcTranscriptionCost(0)).toBe(2); + }); + + it('calculates cost for long recordings', () => { + // 10 minutes = 20 credits + expect(calcTranscriptionCost(600)).toBe(20); + // 60 minutes = 120 credits + expect(calcTranscriptionCost(3600)).toBe(120); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/cleanup.test.ts b/apps/memoro/apps/server/src/routes/cleanup.test.ts new file mode 100644 index 000000000..3edc4ed33 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/cleanup.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for cleanup routes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn(), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => ({ + from: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +vi.mock('../services/cleanup', () => ({ + runAudioCleanup: vi.fn().mockReturnValue(Promise.resolve({ processed: 5, deleted: 3 })), +})); + +function post(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('Cleanup routes auth', () => { + it('rejects requests without internal API key', async () => { + const res = await post('/api/v1/cleanup/run', {}); + expect(res.status).toBe(401); + }); + + it('rejects invalid internal API key', async () => { + const res = await post('/api/v1/cleanup/run', {}, { 'X-Internal-API-Key': 'wrong' }); + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/v1/cleanup/run', () => { + it('triggers async cleanup', async () => { + const res = await post( + '/api/v1/cleanup/run', + {}, + { 'X-Internal-API-Key': 'test-internal-key' } + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.message).toBe('Cleanup started'); + }); +}); + +describe('POST /api/v1/cleanup/manual', () => { + it('runs manual cleanup for all users', async () => { + const res = await post( + '/api/v1/cleanup/manual', + {}, + { 'X-Internal-API-Key': 'test-internal-key' } + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('runs manual cleanup for specific users', async () => { + const res = await post( + '/api/v1/cleanup/manual', + { userIds: ['11111111-2222-3333-4444-555555555555'] }, + { 'X-Internal-API-Key': 'test-internal-key' } + ); + expect(res.status).toBe(200); + }); + + it('rejects invalid UUIDs in userIds', async () => { + const res = await post( + '/api/v1/cleanup/manual', + { userIds: ['not-a-uuid'] }, + { 'X-Internal-API-Key': 'test-internal-key' } + ); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/credits.test.ts b/apps/memoro/apps/server/src/routes/credits.test.ts new file mode 100644 index 000000000..5cc8200b9 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/credits.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for credit routes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn().mockResolvedValue({ hasCredits: true, availableCredits: 100 }), + consumeCredits: vi.fn().mockResolvedValue({ success: true, remaining: 95 }), + getBalance: vi.fn().mockResolvedValue({ balance: 100, totalEarned: 200, totalSpent: 100 }), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.eq = () => chain; + chain.single = () => Promise.resolve({ data: null, error: null }); + chain.maybeSingle = () => Promise.resolve({ data: null, error: null }); + return chain; + }, +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/credits/balance', () => { + it('returns credit balance', async () => { + const res = await app.request('/api/v1/credits/balance'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.credits).toBe(100); + expect(data.totalEarned).toBe(200); + expect(data.totalSpent).toBe(100); + }); +}); + +describe('POST /api/v1/credits/check', () => { + it('validates credits', async () => { + const res = await post('/api/v1/credits/check', { + operation: 'transcription', + amount: 5, + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.hasCredits).toBe(true); + }); + + it('rejects missing operation', async () => { + const res = await post('/api/v1/credits/check', { amount: 5 }); + expect(res.status).toBe(400); + }); + + it('rejects missing amount', async () => { + const res = await post('/api/v1/credits/check', { operation: 'transcription' }); + expect(res.status).toBe(400); + }); + + it('rejects negative amount', async () => { + const res = await post('/api/v1/credits/check', { + operation: 'transcription', + amount: -1, + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/credits/consume', () => { + it('consumes credits', async () => { + const res = await post('/api/v1/credits/consume', { + operation: 'transcription', + amount: 5, + description: 'Memo transcription', + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('rejects missing description', async () => { + const res = await post('/api/v1/credits/consume', { + operation: 'transcription', + amount: 5, + }); + expect(res.status).toBe(400); + }); + + it('rejects empty operation', async () => { + const res = await post('/api/v1/credits/consume', { + operation: '', + amount: 5, + description: 'Test', + }); + expect(res.status).toBe(400); + }); + + it('accepts optional metadata', async () => { + const res = await post('/api/v1/credits/consume', { + operation: 'transcription', + amount: 5, + description: 'With metadata', + metadata: { memoId: 'memo-1' }, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/health.test.ts b/apps/memoro/apps/server/src/routes/health.test.ts new file mode 100644 index 000000000..f61571a62 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/health.test.ts @@ -0,0 +1,41 @@ +/** + * Tests for health check and public routes. + */ + +import { describe, it, expect } from 'vitest'; +import { app } from '../index'; + +describe('GET /health', () => { + it('returns 200 with service info', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.status).toBe('ok'); + expect(data.service).toBe('memoro-server'); + expect(data.runtime).toBe('bun'); + expect(data.timestamp).toBeDefined(); + }); +}); + +describe('GET /api/v1/credits/pricing', () => { + it('returns pricing without auth', async () => { + const res = await app.request('/api/v1/credits/pricing'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.costs).toBeDefined(); + expect(data.costs.TRANSCRIPTION_PER_MINUTE).toBe(2); + expect(data.costs.HEADLINE_GENERATION).toBe(10); + expect(data.costs.QUESTION_MEMO).toBe(5); + expect(data.costs.MEMO_COMBINE).toBe(5); + expect(data.costs.MEETING_RECORDING_PER_MINUTE).toBe(2); + }); +}); + +describe('404 handler', () => { + it('returns 404 for unknown routes', async () => { + const res = await app.request('/nonexistent'); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/internal.test.ts b/apps/memoro/apps/server/src/routes/internal.test.ts new file mode 100644 index 000000000..4ca3a1146 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/internal.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for internal service-to-service routes. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn(), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn().mockResolvedValue(undefined), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +const mockSingle = vi.fn().mockResolvedValue({ data: null, error: null }); +const mockUpdate = vi.fn(); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.update = (data: any) => { + mockUpdate(data); + return chain; + }; + chain.eq = () => chain; + chain.single = () => mockSingle(); + return chain; + }, +})); + +function post(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('Internal routes auth', () => { + it('rejects requests without service key', async () => { + const res = await post('/api/v1/internal/transcription-completed', { + memoId: 'memo-1', + userId: 'user-1', + success: true, + }); + expect(res.status).toBe(401); + }); + + it('rejects invalid service key', async () => { + const res = await post( + '/api/v1/internal/transcription-completed', + { memoId: 'memo-1', userId: 'user-1', success: true }, + { 'X-Service-Key': 'wrong-key' } + ); + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/v1/internal/transcription-completed', () => { + it('processes successful transcription callback', async () => { + const res = await post( + '/api/v1/internal/transcription-completed', + { + memoId: 'memo-1', + userId: 'user-1', + success: true, + transcriptionResult: { + transcript: 'Hello world', + utterances: [{ offset: 0, duration: 1000, text: 'Hello world' }], + languages: ['en'], + primary_language: 'en', + duration: 2.0, + }, + route: 'whisperx', + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.memoId).toBe('memo-1'); + }); + + it('processes error transcription callback', async () => { + const res = await post( + '/api/v1/internal/transcription-completed', + { + memoId: 'memo-1', + userId: 'user-1', + success: false, + error: 'Transcription failed', + fallbackStage: 'azure-batch', + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(200); + }); + + it('rejects missing memoId', async () => { + const res = await post( + '/api/v1/internal/transcription-completed', + { userId: 'user-1', success: true }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(400); + }); + + it('rejects missing userId', async () => { + const res = await post( + '/api/v1/internal/transcription-completed', + { memoId: 'memo-1', success: true }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/internal/append-transcription-completed', () => { + beforeEach(() => { + mockSingle.mockResolvedValue({ + data: { source: { additional_recordings: [{ path: 'test.m4a', status: 'processing' }] } }, + error: null, + }); + }); + + it('processes successful append callback', async () => { + const res = await post( + '/api/v1/internal/append-transcription-completed', + { + memoId: 'memo-1', + userId: 'user-1', + recordingIndex: 0, + success: true, + transcriptionResult: { + transcript: 'Appended text', + languages: ['de'], + primary_language: 'de', + }, + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.recordingIndex).toBe(0); + }); + + it('processes error append callback', async () => { + const res = await post( + '/api/v1/internal/append-transcription-completed', + { + memoId: 'memo-1', + userId: 'user-1', + recordingIndex: 0, + success: false, + error: 'Failed', + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(200); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post( + '/api/v1/internal/append-transcription-completed', + { + memoId: 'nonexistent', + userId: 'user-1', + recordingIndex: 0, + success: true, + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(404); + }); + + it('rejects missing recordingIndex', async () => { + const res = await post( + '/api/v1/internal/append-transcription-completed', + { + memoId: 'memo-1', + userId: 'user-1', + success: true, + }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/internal/batch-metadata', () => { + beforeEach(() => { + mockSingle.mockResolvedValue({ + data: { metadata: {} }, + error: null, + }); + }); + + it('updates batch metadata', async () => { + const res = await post( + '/api/v1/internal/batch-metadata', + { memoId: 'memo-1', jobId: 'job-123' }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.jobId).toBe('job-123'); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post( + '/api/v1/internal/batch-metadata', + { memoId: 'nonexistent', jobId: 'job-123' }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(404); + }); + + it('rejects missing jobId', async () => { + const res = await post( + '/api/v1/internal/batch-metadata', + { memoId: 'memo-1' }, + { 'X-Service-Key': 'test-service-key' } + ); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/meetings.test.ts b/apps/memoro/apps/server/src/routes/meetings.test.ts new file mode 100644 index 000000000..1cd2905c5 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/meetings.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for meeting routes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn().mockResolvedValue({ hasCredits: true, availableCredits: 100 }), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => ({ + from: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +vi.mock('../services/meetings', () => { + const bot = { id: 'bot-1', user_id: 'test-user-id', platform: 'google_meet', status: 'ready' }; + const recording = { + id: 'rec-1', + user_id: 'test-user-id', + audio_url: 'https://example.com/audio.mp4', + duration_seconds: 120, + space_id: null, + }; + return { + createBot: vi.fn().mockResolvedValue(bot), + stopBot: vi.fn().mockResolvedValue(undefined), + getBots: vi.fn().mockResolvedValue([bot]), + getBotById: vi.fn().mockImplementation((id: string) => (id === 'bot-1' ? bot : null)), + getRecordings: vi.fn().mockResolvedValue([recording]), + getRecordingById: vi + .fn() + .mockImplementation((id: string) => (id === 'rec-1' ? recording : null)), + updateBotCredits: vi.fn(), + }; +}); + +function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ── Bot routes ─────────────────────────────────────────────────────────────── + +describe('POST /api/v1/meetings/bots', () => { + it('creates a bot with valid Google Meet URL', async () => { + const res = await post('/api/v1/meetings/bots', { + meeting_url: 'https://meet.google.com/abc-defg-hij', + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.bot).toBeDefined(); + expect(data.creditInfo).toBeDefined(); + }); + + it('creates a bot with Zoom URL', async () => { + const res = await post('/api/v1/meetings/bots', { + meeting_url: 'https://us02web.zoom.us/j/123456789', + }); + expect(res.status).toBe(200); + }); + + it('creates a bot with Teams URL', async () => { + const res = await post('/api/v1/meetings/bots', { + meeting_url: 'https://teams.microsoft.com/l/meetup-join/123', + }); + expect(res.status).toBe(200); + }); + + it('rejects non-meeting URL', async () => { + const res = await post('/api/v1/meetings/bots', { + meeting_url: 'https://example.com/meeting', + }); + expect(res.status).toBe(400); + }); + + it('rejects empty URL', async () => { + const res = await post('/api/v1/meetings/bots', { meeting_url: '' }); + expect(res.status).toBe(400); + }); + + it('returns 402 on insufficient credits', async () => { + const { validateCredits } = await import('@manacore/shared-hono'); + vi.mocked(validateCredits).mockResolvedValueOnce({ + hasCredits: false, + availableCredits: 3, + } as any); + + const res = await post('/api/v1/meetings/bots', { + meeting_url: 'https://meet.google.com/abc-defg-hij', + }); + expect(res.status).toBe(402); + + const data = await res.json(); + expect(data.error).toBe('InsufficientCredits'); + }); +}); + +describe('GET /api/v1/meetings/bots', () => { + it('lists bots', async () => { + const res = await app.request('/api/v1/meetings/bots'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.bots).toHaveLength(1); + }); + + it('supports pagination', async () => { + const res = await app.request('/api/v1/meetings/bots?limit=10&offset=0'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.limit).toBe(10); + expect(data.offset).toBe(0); + }); +}); + +describe('GET /api/v1/meetings/bots/:id', () => { + it('returns a bot by ID', async () => { + const res = await app.request('/api/v1/meetings/bots/bot-1'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.bot.id).toBe('bot-1'); + }); + + it('returns 404 for unknown bot', async () => { + const res = await app.request('/api/v1/meetings/bots/nonexistent'); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/v1/meetings/bots/:id/stop', () => { + it('stops a bot', async () => { + const res = await post('/api/v1/meetings/bots/bot-1/stop', {}); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('returns 404 for unknown bot', async () => { + const { stopBot } = await import('../services/meetings'); + vi.mocked(stopBot).mockRejectedValueOnce(new Error('Bot not found')); + + const res = await post('/api/v1/meetings/bots/nonexistent/stop', {}); + expect(res.status).toBe(404); + }); +}); + +// ── Recording routes ───────────────────────────────────────────────────────── + +describe('GET /api/v1/meetings/recordings', () => { + it('lists recordings', async () => { + const res = await app.request('/api/v1/meetings/recordings'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.recordings).toHaveLength(1); + }); +}); + +describe('GET /api/v1/meetings/recordings/:id', () => { + it('returns a recording by ID', async () => { + const res = await app.request('/api/v1/meetings/recordings/rec-1'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.recording.id).toBe('rec-1'); + }); + + it('returns 404 for unknown recording', async () => { + const res = await app.request('/api/v1/meetings/recordings/nonexistent'); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/memos.test.ts b/apps/memoro/apps/server/src/routes/memos.test.ts new file mode 100644 index 000000000..3b33877cd --- /dev/null +++ b/apps/memoro/apps/server/src/routes/memos.test.ts @@ -0,0 +1,330 @@ +/** + * Tests for memo routes. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn().mockResolvedValue({ hasCredits: true, availableCredits: 100 }), + consumeCredits: vi.fn().mockResolvedValue(true), + getBalance: vi.fn().mockResolvedValue({ balance: 100, totalEarned: 200, totalSpent: 100 }), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi + .fn() + .mockResolvedValue({ memoId: 'memo-123', status: 'processing' }), + callAudioServer: vi.fn().mockResolvedValue(undefined), + handleTranscriptionCompleted: vi.fn().mockResolvedValue(undefined), + updateMemoProcessingStatus: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn().mockResolvedValue({ headline: 'Test', intro: 'Test intro' }), +})); + +const mockSingle = vi.fn().mockResolvedValue({ data: null, error: null }); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.insert = () => chain; + chain.update = () => chain; + chain.eq = () => chain; + chain.in = () => chain; + chain.single = () => mockSingle(); + return chain; + }, +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn().mockResolvedValue('HEADLINE: Combined\nINTRO: Summary\nCONTENT: Full text'), +})); + +function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/memos', () => { + it('creates a memo with valid input', async () => { + const res = await post('/api/v1/memos', { + filePath: 'user/recording.m4a', + duration: 120, + }); + expect(res.status).toBe(201); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.memoId).toBe('memo-123'); + }); + + it('accepts optional spaceId and blueprintId', async () => { + const res = await post('/api/v1/memos', { + filePath: 'user/recording.m4a', + duration: 60, + spaceId: '11111111-2222-3333-4444-555555555555', + blueprintId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(201); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('rejects missing filePath', async () => { + const res = await post('/api/v1/memos', { duration: 120 }); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.success).toBe(false); + }); + + it('rejects missing duration', async () => { + const res = await post('/api/v1/memos', { filePath: 'test.m4a' }); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.success).toBe(false); + }); + + it('returns 402 on insufficient credits', async () => { + const { createMemoFromUploadedFile } = await import('../services/memo'); + vi.mocked(createMemoFromUploadedFile).mockRejectedValueOnce(new Error('Insufficient credits')); + + const res = await post('/api/v1/memos', { + filePath: 'test.m4a', + duration: 60, + }); + expect(res.status).toBe(402); + + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toContain('Insufficient credits'); + }); +}); + +describe('POST /api/v1/memos/:id/append', () => { + beforeEach(() => { + mockSingle.mockResolvedValue({ + data: { id: 'memo-1', user_id: 'test-user-id', source: {} }, + error: null, + }); + }); + + it('appends to a memo with valid input', async () => { + const res = await post('/api/v1/memos/memo-1/append', { + filePath: 'user/append.m4a', + duration: 30, + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.memoId).toBe('memo-1'); + expect(data.recordingIndex).toBeDefined(); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post('/api/v1/memos/nonexistent/append', { + filePath: 'test.m4a', + duration: 30, + }); + expect(res.status).toBe(404); + }); + + it('rejects invalid body', async () => { + const res = await post('/api/v1/memos/memo-1/append', { duration: 30 }); + expect(res.status).toBe(400); + }); + + it('returns 402 if insufficient credits', async () => { + const { validateCredits } = await import('@manacore/shared-hono'); + vi.mocked(validateCredits).mockResolvedValueOnce({ + hasCredits: false, + availableCredits: 0, + } as any); + + const res = await post('/api/v1/memos/memo-1/append', { + filePath: 'test.m4a', + duration: 30, + }); + expect(res.status).toBe(402); + }); +}); + +describe('POST /api/v1/memos/:id/retry-transcription', () => { + it('retries transcription for owned memo', async () => { + mockSingle.mockResolvedValueOnce({ + data: { + id: 'memo-1', + user_id: 'test-user-id', + source: { audio_path: 'test.m4a', duration: 60 }, + metadata: {}, + }, + error: null, + }); + + const res = await post('/api/v1/memos/memo-1/retry-transcription', {}); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post('/api/v1/memos/nonexistent/retry-transcription', {}); + expect(res.status).toBe(404); + }); + + it('returns 400 if no audio file', async () => { + mockSingle.mockResolvedValueOnce({ + data: { id: 'memo-1', user_id: 'test-user-id', source: {}, metadata: {} }, + error: null, + }); + + const res = await post('/api/v1/memos/memo-1/retry-transcription', {}); + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.error).toContain('No audio file'); + }); +}); + +describe('POST /api/v1/memos/:id/retry-headline', () => { + it('retries headline generation', async () => { + mockSingle.mockResolvedValueOnce({ + data: { id: 'memo-1' }, + error: null, + }); + + const res = await post('/api/v1/memos/memo-1/retry-headline', {}); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.headline).toBe('Test'); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post('/api/v1/memos/nonexistent/retry-headline', {}); + expect(res.status).toBe(404); + }); + + it('returns 500 if headline generation fails', async () => { + mockSingle.mockResolvedValueOnce({ + data: { id: 'memo-1' }, + error: null, + }); + + const { processHeadlineForMemo } = await import('../services/headline'); + vi.mocked(processHeadlineForMemo).mockRejectedValueOnce(new Error('AI error')); + + const res = await post('/api/v1/memos/memo-1/retry-headline', {}); + expect(res.status).toBe(500); + }); +}); + +describe('POST /api/v1/memos/combine', () => { + it('rejects fewer than 2 memo IDs', async () => { + const res = await post('/api/v1/memos/combine', { + memoIds: ['11111111-1111-1111-1111-111111111111'], + }); + expect(res.status).toBe(400); + }); + + it('rejects invalid UUIDs', async () => { + const res = await post('/api/v1/memos/combine', { + memoIds: ['not-uuid', 'also-not-uuid'], + }); + expect(res.status).toBe(400); + }); + + it('returns 402 on insufficient credits', async () => { + const { validateCredits } = await import('@manacore/shared-hono'); + vi.mocked(validateCredits).mockResolvedValueOnce({ + hasCredits: false, + availableCredits: 0, + } as any); + + const res = await post('/api/v1/memos/combine', { + memoIds: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'], + }); + expect(res.status).toBe(402); + }); +}); + +describe('POST /api/v1/memos/:id/question', () => { + it('answers a question on a memo', async () => { + mockSingle.mockResolvedValueOnce({ + data: { + id: 'memo-1', + title: 'Test Memo', + source: { transcript: 'This is a test transcript about AI.' }, + }, + error: null, + }); + + const { generateText } = await import('../lib/ai'); + vi.mocked(generateText).mockResolvedValueOnce('The transcript discusses AI.'); + + const res = await post('/api/v1/memos/memo-1/question', { + question: 'What is this about?', + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.answer).toBeDefined(); + expect(data.question).toBe('What is this about?'); + }); + + it('returns 404 if memo not found', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await post('/api/v1/memos/nonexistent/question', { + question: 'What is this?', + }); + expect(res.status).toBe(404); + }); + + it('rejects empty question', async () => { + const res = await post('/api/v1/memos/memo-1/question', { question: '' }); + expect(res.status).toBe(400); + }); + + it('returns 402 on insufficient credits', async () => { + const { validateCredits } = await import('@manacore/shared-hono'); + vi.mocked(validateCredits).mockResolvedValueOnce({ + hasCredits: false, + availableCredits: 0, + } as any); + + const res = await post('/api/v1/memos/memo-1/question', { + question: 'What is this?', + }); + expect(res.status).toBe(402); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/settings.test.ts b/apps/memoro/apps/server/src/routes/settings.test.ts new file mode 100644 index 000000000..47d146315 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/settings.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for settings routes. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn(), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +// Supabase mock that rebuilds chains each call +const mockMaybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); +const mockUpsert = vi.fn().mockResolvedValue({ error: null }); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.eq = () => chain; + chain.maybeSingle = () => mockMaybeSingle(); + chain.upsert = (data: any, opts: any) => mockUpsert(data, opts); + return chain; + }, +})); + +function patch(path: string, body: unknown) { + return app.request(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/settings', () => { + it('returns user settings', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { user_id: 'test-user-id', display_name: 'Test User' }, + error: null, + }); + + const res = await app.request('/api/v1/settings'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.settings).toBeDefined(); + }); + + it('returns empty object if no profile exists', async () => { + mockMaybeSingle.mockResolvedValueOnce({ data: null, error: null }); + + const res = await app.request('/api/v1/settings'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.settings).toEqual({}); + }); +}); + +describe('GET /api/v1/settings/memoro', () => { + it('returns memoro-specific settings', async () => { + mockMaybeSingle.mockResolvedValueOnce({ + data: { + app_settings: { memoro: { autoDeleteAudiosAfter30Days: true } }, + display_name: 'Test', + avatar_url: null, + }, + error: null, + }); + + const res = await app.request('/api/v1/settings/memoro'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.settings.autoDeleteAudiosAfter30Days).toBe(true); + }); + + it('returns empty settings if no profile', async () => { + mockMaybeSingle.mockResolvedValueOnce({ data: null, error: null }); + + const res = await app.request('/api/v1/settings/memoro'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.settings).toEqual({}); + }); +}); + +describe('PATCH /api/v1/settings/memoro', () => { + beforeEach(() => { + mockMaybeSingle.mockResolvedValue({ + data: { app_settings: { memoro: { lang: 'de' } } }, + error: null, + }); + }); + + it('updates memoro settings', async () => { + const res = await patch('/api/v1/settings/memoro', { + autoDeleteAudiosAfter30Days: true, + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('rejects empty body', async () => { + const res = await patch('/api/v1/settings/memoro', {}); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /api/v1/settings/memoro/data-usage', () => { + beforeEach(() => { + mockMaybeSingle.mockResolvedValue({ + data: { app_settings: {} }, + error: null, + }); + }); + + it('accepts data usage', async () => { + const res = await patch('/api/v1/settings/memoro/data-usage', { accepted: true }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.dataUsageAcceptance).toBe(true); + }); + + it('declines data usage', async () => { + const res = await patch('/api/v1/settings/memoro/data-usage', { accepted: false }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.dataUsageAcceptance).toBe(false); + }); + + it('rejects non-boolean', async () => { + const res = await patch('/api/v1/settings/memoro/data-usage', { accepted: 'yes' }); + expect(res.status).toBe(400); + }); + + it('rejects missing accepted field', async () => { + const res = await patch('/api/v1/settings/memoro/data-usage', {}); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /api/v1/settings/profile', () => { + it('updates display name', async () => { + const res = await patch('/api/v1/settings/profile', { display_name: 'New Name' }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('updates avatar URL', async () => { + const res = await patch('/api/v1/settings/profile', { + avatar_url: 'https://example.com/avatar.jpg', + }); + expect(res.status).toBe(200); + }); + + it('updates bio', async () => { + const res = await patch('/api/v1/settings/profile', { bio: 'Hello world' }); + expect(res.status).toBe(200); + }); + + it('rejects empty object', async () => { + const res = await patch('/api/v1/settings/profile', {}); + expect(res.status).toBe(400); + }); + + it('rejects invalid avatar URL', async () => { + const res = await patch('/api/v1/settings/profile', { avatar_url: 'not-a-url' }); + expect(res.status).toBe(400); + }); + + it('rejects bio over 500 chars', async () => { + const res = await patch('/api/v1/settings/profile', { bio: 'x'.repeat(501) }); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/memoro/apps/server/src/routes/spaces.test.ts b/apps/memoro/apps/server/src/routes/spaces.test.ts new file mode 100644 index 000000000..b3a7c544b --- /dev/null +++ b/apps/memoro/apps/server/src/routes/spaces.test.ts @@ -0,0 +1,389 @@ +/** + * Tests for space and invite routes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { app } from '../index'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@manacore/shared-hono', () => ({ + authMiddleware: () => async (c: any, next: any) => { + c.set('userId', 'test-user-id'); + await next(); + }, + errorHandler: (err: any, c: any) => c.json({ error: err.message }, err.status ?? 500), + notFoundHandler: (c: any) => c.json({ error: 'Not found' }, 404), + validateCredits: vi.fn(), + consumeCredits: vi.fn(), + getBalance: vi.fn(), +})); + +vi.mock('../services/memo', () => ({ + createMemoFromUploadedFile: vi.fn(), + callAudioServer: vi.fn(), + handleTranscriptionCompleted: vi.fn(), + updateMemoProcessingStatus: vi.fn(), +})); + +vi.mock('../services/headline', () => ({ + processHeadlineForMemo: vi.fn(), +})); + +vi.mock('../lib/ai', () => ({ + generateText: vi.fn(), +})); + +vi.mock('../services/space', () => { + const space = { id: 'space-1', name: 'Test Space', owner_id: 'test-user-id' }; + const invite = { id: 'invite-1', space_id: 'space-1', email: 'user@example.com' }; + const memo = { id: 'memo-1', title: 'Test Memo' }; + return { + getSpaces: vi.fn().mockResolvedValue([space]), + createSpace: vi.fn().mockResolvedValue(space), + getSpaceDetails: vi.fn().mockResolvedValue(space), + deleteSpace: vi.fn().mockResolvedValue(undefined), + leaveSpace: vi.fn().mockResolvedValue(undefined), + linkMemoToSpace: vi.fn().mockResolvedValue(undefined), + unlinkMemoFromSpace: vi.fn().mockResolvedValue(undefined), + getSpaceMemos: vi.fn().mockResolvedValue({ memos: [memo] }), + getSpaceInvites: vi.fn().mockResolvedValue([invite]), + createInvite: vi.fn().mockResolvedValue(invite), + acceptInvite: vi.fn().mockResolvedValue(undefined), + declineInvite: vi.fn().mockResolvedValue(undefined), + getPendingInvites: vi.fn().mockResolvedValue([invite]), + }; +}); + +const mockSingle = vi.fn().mockResolvedValue({ data: null, error: null }); +const mockDelete = vi.fn(); + +vi.mock('../lib/supabase', () => ({ + createServiceClient: () => { + const chain: any = {}; + chain.from = () => chain; + chain.select = () => chain; + chain.delete = () => { + mockDelete(); + return chain; + }; + chain.eq = () => chain; + chain.single = () => mockSingle(); + return chain; + }, +})); + +function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ── Space routes ───────────────────────────────────────────────────────────── + +describe('GET /api/v1/spaces', () => { + it('lists user spaces', async () => { + const res = await app.request('/api/v1/spaces'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.spaces).toHaveLength(1); + expect(data.total).toBe(1); + }); + + it('supports pagination', async () => { + const res = await app.request('/api/v1/spaces?limit=10&offset=0'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.limit).toBe(10); + expect(data.offset).toBe(0); + }); + + it('rejects invalid pagination', async () => { + const res = await app.request('/api/v1/spaces?limit=200'); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/spaces', () => { + it('creates a space', async () => { + const res = await post('/api/v1/spaces', { name: 'My Space' }); + expect(res.status).toBe(201); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.space.name).toBe('Test Space'); + }); + + it('creates a space with description', async () => { + const res = await post('/api/v1/spaces', { + name: 'My Space', + description: 'A great space', + }); + expect(res.status).toBe(201); + }); + + it('rejects empty name', async () => { + const res = await post('/api/v1/spaces', { name: '' }); + expect(res.status).toBe(400); + }); + + it('rejects missing name', async () => { + const res = await post('/api/v1/spaces', {}); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/v1/spaces/:id', () => { + it('returns space details', async () => { + const res = await app.request('/api/v1/spaces/space-1'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.space.id).toBe('space-1'); + }); + + it('returns 403 for access denied', async () => { + const { getSpaceDetails } = await import('../services/space'); + vi.mocked(getSpaceDetails).mockRejectedValueOnce(new Error('Access denied')); + + const res = await app.request('/api/v1/spaces/other-space'); + expect(res.status).toBe(403); + }); + + it('returns 404 for non-existent space', async () => { + const { getSpaceDetails } = await import('../services/space'); + vi.mocked(getSpaceDetails).mockRejectedValueOnce(new Error('Space not found')); + + const res = await app.request('/api/v1/spaces/nonexistent'); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/v1/spaces/:id', () => { + it('deletes a space', async () => { + const res = await app.request('/api/v1/spaces/space-1', { method: 'DELETE' }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('returns 403 if not owner', async () => { + const { deleteSpace } = await import('../services/space'); + vi.mocked(deleteSpace).mockRejectedValueOnce(new Error('Only owner can delete')); + + const res = await app.request('/api/v1/spaces/space-1', { method: 'DELETE' }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /api/v1/spaces/:id/leave', () => { + it('leaves a space', async () => { + const res = await post('/api/v1/spaces/space-1/leave', {}); + expect(res.status).toBe(200); + }); + + it('returns 400 if owner tries to leave', async () => { + const { leaveSpace } = await import('../services/space'); + vi.mocked(leaveSpace).mockRejectedValueOnce(new Error('owner cannot leave')); + + const res = await post('/api/v1/spaces/space-1/leave', {}); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/spaces/:id/memos/link', () => { + it('links a memo to a space', async () => { + const res = await post('/api/v1/spaces/space-1/memos/link', { + memoId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(200); + }); + + it('rejects invalid memoId', async () => { + const res = await post('/api/v1/spaces/space-1/memos/link', { + memoId: 'not-a-uuid', + }); + expect(res.status).toBe(400); + }); + + it('returns 403 if not a member', async () => { + const { linkMemoToSpace } = await import('../services/space'); + vi.mocked(linkMemoToSpace).mockRejectedValueOnce(new Error('Not a member')); + + const res = await post('/api/v1/spaces/space-1/memos/link', { + memoId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /api/v1/spaces/:id/memos/unlink', () => { + it('unlinks a memo from a space', async () => { + const res = await post('/api/v1/spaces/space-1/memos/unlink', { + memoId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(200); + }); +}); + +describe('GET /api/v1/spaces/:id/memos', () => { + it('lists space memos', async () => { + const res = await app.request('/api/v1/spaces/space-1/memos'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.memos).toHaveLength(1); + }); + + it('returns 403 if not a member', async () => { + const { getSpaceMemos } = await import('../services/space'); + vi.mocked(getSpaceMemos).mockRejectedValueOnce(new Error('Not a member')); + + const res = await app.request('/api/v1/spaces/other-space/memos'); + expect(res.status).toBe(403); + }); +}); + +describe('GET /api/v1/spaces/:id/invites', () => { + it('lists space invites', async () => { + const res = await app.request('/api/v1/spaces/space-1/invites'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.invites).toHaveLength(1); + }); +}); + +describe('POST /api/v1/spaces/:id/invite', () => { + it('creates an invite', async () => { + const res = await post('/api/v1/spaces/space-1/invite', { + email: 'user@example.com', + }); + expect(res.status).toBe(201); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('rejects invalid email', async () => { + const res = await post('/api/v1/spaces/space-1/invite', { + email: 'not-an-email', + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/spaces/invites/:inviteId/resend', () => { + it('returns success (stub)', async () => { + const res = await post('/api/v1/spaces/invites/invite-1/resend', {}); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); +}); + +describe('DELETE /api/v1/spaces/invites/:inviteId', () => { + it('cancels an invite as inviter', async () => { + mockSingle.mockResolvedValueOnce({ + data: { id: 'invite-1', inviter_id: 'test-user-id', space_id: 'space-1' }, + error: null, + }); + mockSingle.mockResolvedValueOnce({ + data: { role: 'member' }, + error: null, + }); + + const res = await app.request('/api/v1/spaces/invites/invite-1', { method: 'DELETE' }); + expect(res.status).toBe(200); + }); + + it('returns 404 for non-existent invite', async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: 'not found' } }); + + const res = await app.request('/api/v1/spaces/invites/nonexistent', { method: 'DELETE' }); + expect(res.status).toBe(404); + }); + + it('returns 403 if not authorized', async () => { + mockSingle.mockResolvedValueOnce({ + data: { id: 'invite-1', inviter_id: 'other-user', space_id: 'space-1' }, + error: null, + }); + mockSingle.mockResolvedValueOnce({ + data: { role: 'member' }, + error: null, + }); + + const res = await app.request('/api/v1/spaces/invites/invite-1', { method: 'DELETE' }); + expect(res.status).toBe(403); + }); +}); + +// ── Invite routes (/api/v1/invites) ────────────────────────────────────────── + +describe('GET /api/v1/invites/pending', () => { + it('lists pending invites', async () => { + const res = await app.request('/api/v1/invites/pending'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.invites).toHaveLength(1); + }); +}); + +describe('POST /api/v1/invites/accept', () => { + it('accepts an invite', async () => { + const res = await post('/api/v1/invites/accept', { + inviteId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('rejects invalid inviteId', async () => { + const res = await post('/api/v1/invites/accept', { inviteId: 'not-a-uuid' }); + expect(res.status).toBe(400); + }); + + it('returns 404 for non-existent invite', async () => { + const { acceptInvite } = await import('../services/space'); + vi.mocked(acceptInvite).mockRejectedValueOnce(new Error('Invite not found')); + + const res = await post('/api/v1/invites/accept', { + inviteId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/v1/invites/decline', () => { + it('declines an invite', async () => { + const res = await post('/api/v1/invites/decline', { + inviteId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(200); + }); + + it('returns 404 for already-processed invite', async () => { + const { declineInvite } = await import('../services/space'); + vi.mocked(declineInvite).mockRejectedValueOnce(new Error('already processed')); + + const res = await post('/api/v1/invites/decline', { + inviteId: '11111111-2222-3333-4444-555555555555', + }); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/memoro/apps/server/vitest.config.ts b/apps/memoro/apps/server/vitest.config.ts index 17eeaa786..d84744d7e 100644 --- a/apps/memoro/apps/server/vitest.config.ts +++ b/apps/memoro/apps/server/vitest.config.ts @@ -7,7 +7,5 @@ export default defineConfig({ include: ['src/**/*.test.ts'], setupFiles: ['./src/test-setup.ts'], clearMocks: true, - mockReset: true, - restoreMocks: true, }, });