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>
This commit is contained in:
parent
1c231d6659
commit
772609c919
67
dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts
Normal file
67
dashboards/tracker-web/src/__tests__/auth-mfa-verify.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
93
dashboards/tracker-web/src/__tests__/auth-oauth.test.ts
Normal file
93
dashboards/tracker-web/src/__tests__/auth-oauth.test.ts
Normal file
@ -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<string, string> = {}): 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');
|
||||
});
|
||||
});
|
||||
37
dashboards/tracker-web/src/__tests__/product-config.test.ts
Normal file
37
dashboards/tracker-web/src/__tests__/product-config.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
187
dashboards/tracker-web/src/__tests__/tracker-client.test.ts
Normal file
187
dashboards/tracker-web/src/__tests__/tracker-client.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user