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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 16:07:03 +02:00
parent f6cbba9f2a
commit 32e8edfb66
10 changed files with 1771 additions and 2 deletions

View file

@ -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);
});
});

View file

@ -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<string, string>) {
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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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<string, string>) {
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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -7,7 +7,5 @@ export default defineConfig({
include: ['src/**/*.test.ts'],
setupFiles: ['./src/test-setup.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});