From 772609c9198cfafd5a6ec332413012e2f9efd7a6 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 28 May 2026 20:05:33 -0700 Subject: [PATCH] test(tracker-web): cover untested API routes + tracker-client, enforce coverage Add unit tests for the previously-untested proxy handlers (auth/mfa/verify, auth/oauth/[provider], telemetry/ingest) covering success, error-status forwarding, default error messages, validation, and the 502 unreachable path. Add tracker-client tests asserting every endpoint path/method/body contract and x-product-id header injection, plus product-config request resolution. Overall coverage rises from ~90% to 94% stmts / 87% branch / 94% funcs. Also fix the vitest coverage thresholds: the legacy global nesting was silently ignored by the v8 provider, so the 80% gate was never enforced. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/__tests__/auth-mfa-verify.test.ts | 67 +++++++ .../src/__tests__/auth-oauth.test.ts | 93 +++++++++ .../src/__tests__/product-config.test.ts | 37 ++++ .../src/__tests__/telemetry-ingest.test.ts | 55 ++++++ .../src/__tests__/tracker-client.test.ts | 187 ++++++++++++++++++ dashboards/tracker-web/vitest.config.ts | 12 +- 6 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts create mode 100644 dashboards/tracker-web/src/__tests__/auth-oauth.test.ts create mode 100644 dashboards/tracker-web/src/__tests__/product-config.test.ts create mode 100644 dashboards/tracker-web/src/__tests__/telemetry-ingest.test.ts create mode 100644 dashboards/tracker-web/src/__tests__/tracker-client.test.ts diff --git a/dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts b/dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts new file mode 100644 index 00000000..fa17417e --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for POST /api/auth/mfa/verify (tracker dashboard — proxy to platform-service). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { POST } from '@/app/api/auth/mfa/verify/route'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + } as unknown as Response; +} + +function callVerify(body: object) { + return POST({ json: async () => body } as never); +} + +describe('POST /api/auth/mfa/verify (tracker)', () => { + beforeEach(() => mockFetch.mockReset()); + + it('forwards a successful verification and returns tokens', async () => { + mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok', user: { id: 'u1' } })); + + const res = await callVerify({ challengeToken: 'chal', code: '123456', method: 'totp' }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.accessToken).toBe('tok'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/auth/mfa/verify'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('forwards the platform-service error status and message', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'Invalid code' }, 401)); + + const res = await callVerify({ challengeToken: 'chal', code: '000000', method: 'totp' }); + + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Invalid code'); + }); + + it('uses a default error message when platform-service omits one', async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 403)); + + const res = await callVerify({ challengeToken: 'chal', code: '000000' }); + + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('MFA verification failed'); + }); + + it('returns 502 when the platform-service is unreachable', async () => { + mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED'))); + + const res = await callVerify({ challengeToken: 'chal', code: '123456' }); + + expect(res.status).toBe(502); + expect((await res.json()).error).toBe('Platform service unavailable'); + }); +}); diff --git a/dashboards/tracker-web/src/__tests__/auth-oauth.test.ts b/dashboards/tracker-web/src/__tests__/auth-oauth.test.ts new file mode 100644 index 00000000..c8975dd0 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/auth-oauth.test.ts @@ -0,0 +1,93 @@ +/** + * Tests for POST /api/auth/oauth/[provider] (tracker dashboard — proxy to platform-service). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +import { POST } from '@/app/api/auth/oauth/[provider]/route'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + } as unknown as Response; +} + +function mockRequest(body: object, headers: Record = {}): NextRequest { + const headerMap = new Map(Object.entries(headers)); + return { + json: async () => body, + headers: { get: (k: string) => headerMap.get(k.toLowerCase()) ?? null }, + } as unknown as NextRequest; +} + +function callOAuth(req: NextRequest, provider = 'google') { + return POST(req, { params: Promise.resolve({ provider }) }); +} + +describe('POST /api/auth/oauth/[provider] (tracker)', () => { + beforeEach(() => mockFetch.mockReset()); + + it('rejects requests without an idToken (400) and never calls the backend', async () => { + const res = await callOAuth(mockRequest({})); + + expect(res.status).toBe(400); + expect((await res.json()).error).toBe('idToken required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('proxies the provider login and returns tokens on success', async () => { + mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok' })); + + const res = await callOAuth(mockRequest({ idToken: 'google-id-token' }), 'google'); + + expect(res.status).toBe(200); + expect((await res.json()).accessToken).toBe('tok'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/auth/oauth/google'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('injects the x-product-id header value into the forwarded body', async () => { + mockFetch.mockResolvedValue(jsonResponse({ accessToken: 'tok' })); + + await callOAuth(mockRequest({ idToken: 'tok' }, { 'x-product-id': 'chronomind' })); + + const forwardedBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(forwardedBody.productId).toBe('chronomind'); + expect(forwardedBody.idToken).toBe('tok'); + }); + + it('forwards the platform-service error status', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'unverified' }, 401)); + + const res = await callOAuth(mockRequest({ idToken: 'tok' })); + + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('unverified'); + }); + + it('uses a provider-specific default error message', async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 403)); + + const res = await callOAuth(mockRequest({ idToken: 'tok' }), 'github'); + + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('OAuth github login failed'); + }); + + it('returns 502 when the platform-service is unreachable', async () => { + mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED'))); + + const res = await callOAuth(mockRequest({ idToken: 'tok' })); + + expect(res.status).toBe(502); + expect((await res.json()).error).toBe('Platform service unavailable'); + }); +}); diff --git a/dashboards/tracker-web/src/__tests__/product-config.test.ts b/dashboards/tracker-web/src/__tests__/product-config.test.ts new file mode 100644 index 00000000..45fea8e5 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/product-config.test.ts @@ -0,0 +1,37 @@ +/** + * Tests for src/lib/product-config — request productId resolution + identity exports. + */ + +import { describe, it, expect } from 'vitest'; +import type { NextRequest } from 'next/server'; + +import { getRequestProductId, PRODUCT_ID, KNOWN_PRODUCTS } from '@/lib/product-config'; + +function reqWithHeader(value: string | null): NextRequest { + return { + headers: { get: (k: string) => (k.toLowerCase() === 'x-product-id' ? value : null) }, + } as unknown as NextRequest; +} + +describe('getRequestProductId', () => { + it('returns the x-product-id header when present', () => { + expect(getRequestProductId(reqWithHeader('chronomind'))).toBe('chronomind'); + }); + + it('falls back to the configured PRODUCT_ID when the header is absent', () => { + expect(getRequestProductId(reqWithHeader(null))).toBe(PRODUCT_ID); + }); +}); + +describe('product identity exports', () => { + it('exposes a non-empty PRODUCT_ID string', () => { + expect(typeof PRODUCT_ID).toBe('string'); + expect(PRODUCT_ID.length).toBeGreaterThan(0); + }); + + it('lists known products with unique ids', () => { + const ids = KNOWN_PRODUCTS.map(p => p.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/dashboards/tracker-web/src/__tests__/telemetry-ingest.test.ts b/dashboards/tracker-web/src/__tests__/telemetry-ingest.test.ts new file mode 100644 index 00000000..af1f3b41 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/telemetry-ingest.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for POST /api/telemetry/ingest (tracker dashboard — proxy to platform-service). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +import { POST } from '@/app/api/telemetry/ingest/route'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockRequest(body: string): NextRequest { + return { text: async () => body } as unknown as NextRequest; +} + +describe('POST /api/telemetry/ingest (tracker)', () => { + beforeEach(() => mockFetch.mockReset()); + + it('forwards the raw beacon body to the telemetry events endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200 } as Response); + + const payload = JSON.stringify({ events: [{ eventName: 'page_view' }] }); + const res = await POST(mockRequest(payload)); + + expect(res.status).toBe(200); + expect((await res.json()).ok).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/telemetry/events'), + expect.objectContaining({ + method: 'POST', + body: payload, + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }) + ); + }); + + it('mirrors a non-ok platform-service status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 429 } as Response); + + const res = await POST(mockRequest('{}')); + + expect(res.status).toBe(429); + expect((await res.json()).ok).toBe(false); + }); + + it('returns 502 when the platform-service is unreachable', async () => { + mockFetch.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED'))); + + const res = await POST(mockRequest('{}')); + + expect(res.status).toBe(502); + expect((await res.json()).ok).toBe(false); + }); +}); diff --git a/dashboards/tracker-web/src/__tests__/tracker-client.test.ts b/dashboards/tracker-web/src/__tests__/tracker-client.test.ts new file mode 100644 index 00000000..d10f95b0 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/tracker-client.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for src/lib/tracker-client — request path/header construction. + * + * The shared @bytelyst/api-client is mocked so we can assert exactly which + * path + options the tracker client forwards, without any real network. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() })); + +vi.mock('@bytelyst/api-client', () => ({ + createApiClient: () => ({ fetch: fetchSpy }), +})); + +import { + listItems, + getItem, + createItem, + updateItem, + updateItemStatus, + deleteItem, + getStats, + listComments, + addComment, + toggleVote, + getRoadmapItems, + getRoadmapStats, + getPublicItem, + submitPublicItem, + publicVote, +} from '@/lib/tracker-client'; + +describe('tracker-client (authenticated API)', () => { + beforeEach(() => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({}); + }); + + it('builds a query string for listItems', async () => { + await listItems({ status: 'open', limit: '10' }); + const [path] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items?status=open&limit=10'); + }); + + it('omits the query string when no params are passed', async () => { + await listItems(); + expect(fetchSpy.mock.calls[0][0]).toBe('/items'); + }); + + it('requests a single item by id', async () => { + await getItem('item_42'); + expect(fetchSpy.mock.calls[0][0]).toBe('/items/item_42'); + }); + + it('POSTs a serialized body when creating an item', async () => { + await createItem({ title: 'Bug', type: 'bug' }); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ title: 'Bug', type: 'bug' }); + }); + + it('PATCHes the status sub-resource', async () => { + await updateItemStatus('item_1', 'done'); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items/item_1/status'); + expect(options.method).toBe('PATCH'); + expect(JSON.parse(options.body)).toEqual({ status: 'done' }); + }); + + it('DELETEs an item by id', async () => { + await deleteItem('item_9'); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items/item_9'); + expect(options.method).toBe('DELETE'); + }); + + it('PUTs an updated item', async () => { + await updateItem('item_1', { title: 'Renamed' }); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items/item_1'); + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body)).toEqual({ title: 'Renamed' }); + }); + + it('requests stats with an optional productId query', async () => { + await getStats('chronomind'); + expect(fetchSpy.mock.calls[0][0]).toBe('/items/stats?productId=chronomind'); + }); + + it('requests stats without a query when no productId is given', async () => { + await getStats(); + expect(fetchSpy.mock.calls[0][0]).toBe('/items/stats'); + }); + + it('lists comments for an item', async () => { + await listComments('item_1'); + expect(fetchSpy.mock.calls[0][0]).toBe('/items/item_1/comments'); + }); + + it('POSTs a new comment body', async () => { + await addComment('item_1', 'Looks good'); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items/item_1/comments'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ body: 'Looks good' }); + }); + + it('POSTs a vote toggle', async () => { + await toggleVote('item_1'); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/items/item_1/vote'); + expect(options.method).toBe('POST'); + }); +}); + +describe('tracker-client (public roadmap API)', () => { + beforeEach(() => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({}); + }); + + it('builds the public roadmap path with query params', async () => { + await getRoadmapItems({ sortBy: 'voteCount', limit: '100' }); + expect(fetchSpy.mock.calls[0][0]).toBe('/public/roadmap?sortBy=voteCount&limit=100'); + }); + + it('POSTs to the public submit endpoint', async () => { + await submitPublicItem({ title: 'Idea', email: 'a@b.com', name: 'A' }); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/public/submit'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body).title).toBe('Idea'); + }); + + it('requests public roadmap stats with optional productId', async () => { + await getRoadmapStats('lysnrai'); + expect(fetchSpy.mock.calls[0][0]).toBe('/public/roadmap/stats?productId=lysnrai'); + }); + + it('requests a single public item by id', async () => { + await getPublicItem('item_7'); + expect(fetchSpy.mock.calls[0][0]).toBe('/public/items/item_7'); + }); + + it('POSTs a public vote with the voter email', async () => { + await publicVote('item_7', 'voter@example.com'); + const [path, options] = fetchSpy.mock.calls[0]; + expect(path).toBe('/public/items/item_7/vote'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ email: 'voter@example.com' }); + }); +}); + +describe('tracker-client product header injection', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({}); + }); + + it('adds x-product-id from localStorage when running in the browser', async () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('localStorage', { + getItem: (key: string) => (key === 'tracker_selected_product' ? 'chronomind' : null), + }); + + await listItems(); + + const options = fetchSpy.mock.calls[0][1]; + expect(options.headers['x-product-id']).toBe('chronomind'); + }); + + it('does not add x-product-id when no product is selected', async () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('localStorage', { getItem: () => null }); + + await listItems(); + + const options = fetchSpy.mock.calls[0][1]; + expect(options.headers['x-product-id']).toBeUndefined(); + }); +}); diff --git a/dashboards/tracker-web/vitest.config.ts b/dashboards/tracker-web/vitest.config.ts index 75d821d3..40370b4a 100644 --- a/dashboards/tracker-web/vitest.config.ts +++ b/dashboards/tracker-web/vitest.config.ts @@ -18,13 +18,13 @@ export default defineConfig({ '**/*.config.*', '**/e2e/**', ], + // Vitest reads these keys directly under `thresholds` (the legacy `global` + // nesting is ignored by the v8 provider and silently disables enforcement). thresholds: { - global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, - }, + branches: 80, + functions: 80, + lines: 80, + statements: 80, }, }, },