From 8e12409938f071d9bfd34f218ba33bef53a2ca6a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 3 Apr 2026 00:13:13 -0700 Subject: [PATCH] =?UTF-8?q?test(cowork-service):=20integration=20tests=20f?= =?UTF-8?q?or=20proxy=20routes=20=E2=80=94=2080=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/modules/audit/routes.test.ts | 101 ++++++++++++ .../src/modules/extraction/routes.test.ts | 108 +++++++++++++ .../src/modules/notifications/routes.test.ts | 147 ++++++++++++++++++ .../src/modules/usage/routes.test.ts | 111 +++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 services/cowork-service/src/modules/audit/routes.test.ts create mode 100644 services/cowork-service/src/modules/extraction/routes.test.ts create mode 100644 services/cowork-service/src/modules/notifications/routes.test.ts create mode 100644 services/cowork-service/src/modules/usage/routes.test.ts diff --git a/services/cowork-service/src/modules/audit/routes.test.ts b/services/cowork-service/src/modules/audit/routes.test.ts new file mode 100644 index 00000000..a71853a8 --- /dev/null +++ b/services/cowork-service/src/modules/audit/routes.test.ts @@ -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); + }); + }); +}); diff --git a/services/cowork-service/src/modules/extraction/routes.test.ts b/services/cowork-service/src/modules/extraction/routes.test.ts new file mode 100644 index 00000000..e2465464 --- /dev/null +++ b/services/cowork-service/src/modules/extraction/routes.test.ts @@ -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); + }); + }); +}); diff --git a/services/cowork-service/src/modules/notifications/routes.test.ts b/services/cowork-service/src/modules/notifications/routes.test.ts new file mode 100644 index 00000000..9267964b --- /dev/null +++ b/services/cowork-service/src/modules/notifications/routes.test.ts @@ -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); + }); + }); +}); diff --git a/services/cowork-service/src/modules/usage/routes.test.ts b/services/cowork-service/src/modules/usage/routes.test.ts new file mode 100644 index 00000000..8d7bdbc8 --- /dev/null +++ b/services/cowork-service/src/modules/usage/routes.test.ts @@ -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); + }); + }); +});