mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
f6cbba9f2a
commit
32e8edfb66
10 changed files with 1771 additions and 2 deletions
55
apps/memoro/apps/server/src/lib/credits.test.ts
Normal file
55
apps/memoro/apps/server/src/lib/credits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
apps/memoro/apps/server/src/routes/cleanup.test.ts
Normal file
120
apps/memoro/apps/server/src/routes/cleanup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
apps/memoro/apps/server/src/routes/credits.test.ts
Normal file
143
apps/memoro/apps/server/src/routes/credits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
apps/memoro/apps/server/src/routes/health.test.ts
Normal file
41
apps/memoro/apps/server/src/routes/health.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
264
apps/memoro/apps/server/src/routes/internal.test.ts
Normal file
264
apps/memoro/apps/server/src/routes/internal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
215
apps/memoro/apps/server/src/routes/meetings.test.ts
Normal file
215
apps/memoro/apps/server/src/routes/meetings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
330
apps/memoro/apps/server/src/routes/memos.test.ts
Normal file
330
apps/memoro/apps/server/src/routes/memos.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
214
apps/memoro/apps/server/src/routes/settings.test.ts
Normal file
214
apps/memoro/apps/server/src/routes/settings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
389
apps/memoro/apps/server/src/routes/spaces.test.ts
Normal file
389
apps/memoro/apps/server/src/routes/spaces.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,5 @@ export default defineConfig({
|
|||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
clearMocks: true,
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue