test(cowork-service): integration tests for proxy routes — 80 tests passing

Add test files for all 4 new proxy route modules:
- audit/routes.test.ts (5 tests): query, stats, 502 fallback, non-ok forward
- usage/routes.test.ts (5 tests): summary, check-limits, 502 fallback, non-ok forward
- notifications/routes.test.ts (8 tests): prefs get/put, webhooks CRUD, 502 fallback
- extraction/routes.test.ts (5 tests): extract, models, 502 fallback, non-ok forward

All tests use mocked fetch to verify proxy behavior without real services.
80 tests passing (13 test files), typecheck clean.
This commit is contained in:
saravanakumardb1 2026-04-03 00:13:13 -07:00
parent 62997bb1db
commit 8e12409938
4 changed files with 467 additions and 0 deletions

View File

@ -0,0 +1,101 @@
import { describe, expect, it, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/config.js', () => ({
config: {
SERVICE_NAME: 'cowork-service',
PLATFORM_SERVICE_URL: 'http://mock-platform:4003',
},
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
}));
import { auditRoutes } from './routes.js';
let app: FastifyApp;
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeAll(async () => {
app = await createServiceApp({
name: 'audit-routes-test',
version: '0.0.1',
logger: false,
});
await app.register(auditRoutes);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
mockFetch.mockReset();
});
describe('audit proxy routes', () => {
describe('GET /api/audit', () => {
it('proxies to platform-service and returns records', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ records: [{ id: 'aud_1', action: 'tool_call' }], count: 1 }),
});
const res = await app.inject({ method: 'GET', url: '/api/audit?limit=50&days=7' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.records).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledOnce();
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toContain('mock-platform:4003/audit');
expect(fetchUrl).toContain('limit=50');
expect(fetchUrl).toContain('days=7');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/audit' });
expect(res.statusCode).toBe(502);
const body = JSON.parse(res.payload);
expect(body.error).toBe('Platform-service unavailable');
});
it('forwards platform non-ok status', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 403 });
const res = await app.inject({ method: 'GET', url: '/api/audit' });
expect(res.statusCode).toBe(403);
});
});
describe('GET /api/audit/stats', () => {
it('proxies stats request to platform-service', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ stats: { total: 42 }, days: 30 }),
});
const res = await app.inject({ method: 'GET', url: '/api/audit/stats?days=14' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.stats.total).toBe(42);
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toContain('days=14');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/audit/stats' });
expect(res.statusCode).toBe(502);
});
});
});

View File

@ -0,0 +1,108 @@
import { describe, expect, it, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/config.js', () => ({
config: {
SERVICE_NAME: 'cowork-service',
EXTRACTION_SERVICE_URL: 'http://mock-extraction:4005',
},
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
}));
import { extractionRoutes } from './routes.js';
let app: FastifyApp;
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeAll(async () => {
app = await createServiceApp({
name: 'extraction-routes-test',
version: '0.0.1',
logger: false,
});
await app.register(extractionRoutes);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
mockFetch.mockReset();
});
describe('extraction proxy routes', () => {
describe('POST /api/extract', () => {
it('proxies extraction request to extraction-service', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ entities: [{ type: 'person', name: 'Alice' }], text: 'Hello Alice' }),
});
const res = await app.inject({
method: 'POST',
url: '/api/extract',
payload: { text: 'Hello Alice', task: 'entity-extraction' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.entities).toHaveLength(1);
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toBe('http://mock-extraction:4005/extract');
});
it('returns 502 when extraction-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({
method: 'POST',
url: '/api/extract',
payload: { text: 'test' },
});
expect(res.statusCode).toBe(502);
const body = JSON.parse(res.payload);
expect(body.error).toBe('Extraction service unavailable');
});
it('forwards extraction-service non-ok status', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 422 });
const res = await app.inject({
method: 'POST',
url: '/api/extract',
payload: { text: 'test' },
});
expect(res.statusCode).toBe(422);
});
});
describe('GET /api/extract/models', () => {
it('lists available extraction models', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ models: ['gemini-2.5-flash', 'gpt-4o-mini'], default: 'gemini-2.5-flash' }),
});
const res = await app.inject({ method: 'GET', url: '/api/extract/models' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.models).toContain('gemini-2.5-flash');
});
it('returns 502 when extraction-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/extract/models' });
expect(res.statusCode).toBe(502);
});
});
});

View File

@ -0,0 +1,147 @@
import { describe, expect, it, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/config.js', () => ({
config: {
SERVICE_NAME: 'cowork-service',
PLATFORM_SERVICE_URL: 'http://mock-platform:4003',
},
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'demo-user'),
}));
import { notificationRoutes } from './routes.js';
let app: FastifyApp;
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeAll(async () => {
app = await createServiceApp({
name: 'notification-routes-test',
version: '0.0.1',
logger: false,
});
await app.register(notificationRoutes);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
mockFetch.mockReset();
});
describe('notification proxy routes', () => {
describe('GET /api/notifications/prefs', () => {
it('proxies prefs request with userId', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ email: true, push: false, inApp: true }),
});
const res = await app.inject({ method: 'GET', url: '/api/notifications/prefs' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.email).toBe(true);
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toContain('notifications/prefs/demo-user');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/notifications/prefs' });
expect(res.statusCode).toBe(502);
});
});
describe('PUT /api/notifications/prefs', () => {
it('proxies prefs update with body', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ email: false, push: true, inApp: true }),
});
const res = await app.inject({
method: 'PUT',
url: '/api/notifications/prefs',
payload: { email: false, push: true },
});
expect(res.statusCode).toBe(200);
expect(mockFetch.mock.calls[0][1].method).toBe('PUT');
});
});
describe('GET /api/webhooks', () => {
it('lists webhook subscriptions', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ subscriptions: [{ id: 'wh_1', url: 'https://example.com' }] }),
});
const res = await app.inject({ method: 'GET', url: '/api/webhooks' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.subscriptions).toHaveLength(1);
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/webhooks' });
expect(res.statusCode).toBe(502);
});
});
describe('POST /api/webhooks', () => {
it('creates a webhook subscription', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 'wh_new', url: 'https://example.com/hook' }),
});
const res = await app.inject({
method: 'POST',
url: '/api/webhooks',
payload: { url: 'https://example.com/hook', events: ['task.completed'] },
});
expect(res.statusCode).toBe(201);
});
});
describe('DELETE /api/webhooks/:id', () => {
it('deletes a webhook subscription', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const res = await app.inject({ method: 'DELETE', url: '/api/webhooks/wh_1' });
expect(res.statusCode).toBe(200);
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toContain('webhooks/subscriptions/wh_1');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'DELETE', url: '/api/webhooks/wh_1' });
expect(res.statusCode).toBe(502);
});
});
});

View File

@ -0,0 +1,111 @@
import { describe, expect, it, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/config.js', () => ({
config: {
SERVICE_NAME: 'cowork-service',
PLATFORM_SERVICE_URL: 'http://mock-platform:4003',
},
}));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'demo-user'),
}));
import { usageRoutes } from './routes.js';
let app: FastifyApp;
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeAll(async () => {
app = await createServiceApp({
name: 'usage-routes-test',
version: '0.0.1',
logger: false,
});
await app.register(usageRoutes);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
mockFetch.mockReset();
});
describe('usage proxy routes', () => {
describe('GET /api/usage/summary', () => {
it('proxies summary request with userId and days', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ totalTokens: 5000, totalCost: 0.12, days: 7 }),
});
const res = await app.inject({ method: 'GET', url: '/api/usage/summary?days=7' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.totalTokens).toBe(5000);
const fetchUrl = mockFetch.mock.calls[0][0] as string;
expect(fetchUrl).toContain('userId=demo-user');
expect(fetchUrl).toContain('days=7');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({ method: 'GET', url: '/api/usage/summary' });
expect(res.statusCode).toBe(502);
const body = JSON.parse(res.payload);
expect(body.error).toBe('Platform-service unavailable');
});
it('forwards platform non-ok status', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500 });
const res = await app.inject({ method: 'GET', url: '/api/usage/summary' });
expect(res.statusCode).toBe(500);
});
});
describe('POST /api/usage/check-limits', () => {
it('proxies check-limits with userId and plan', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ exceeded: [], warnings: [], withinLimits: true }),
});
const res = await app.inject({
method: 'POST',
url: '/api/usage/check-limits',
payload: { plan: 'pro' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.withinLimits).toBe(true);
const fetchBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(fetchBody.userId).toBe('demo-user');
expect(fetchBody.plan).toBe('pro');
});
it('returns 502 when platform-service is unreachable', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const res = await app.inject({
method: 'POST',
url: '/api/usage/check-limits',
payload: { plan: 'free' },
});
expect(res.statusCode).toBe(502);
});
});
});