merge(feat/ai-cov-frontend): coverage gate green

This commit is contained in:
lakshit verma 2026-08-19 20:04:55 +05:30
commit 9d6258f21e
No known key found for this signature in database
4 changed files with 692 additions and 0 deletions

View file

@ -0,0 +1,181 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postEventStream } from './stream';
interface MockReader {
read: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
}
function okResponse(chunks: Uint8Array[]): { ok: true; status: number; body: { getReader: () => MockReader }; json: ReturnType<typeof vi.fn> } {
const reads: Array<{ done: boolean; value: Uint8Array }> = [
...chunks.map((value) => ({ done: false, value })),
{ done: true, value: new Uint8Array(0) },
];
const reader: MockReader = {
read: vi.fn().mockImplementation(() => Promise.resolve(reads.shift()!)),
releaseLock: vi.fn(),
cancel: vi.fn().mockResolvedValue(undefined),
};
return {
ok: true,
status: 200,
body: { getReader: () => reader },
json: vi.fn(),
};
}
function errorResponse(status: number, body: unknown): {
ok: false;
status: number;
body: null;
json: ReturnType<typeof vi.fn>;
} {
return { ok: false, status, body: null, json: vi.fn().mockResolvedValue(body) };
}
describe('postEventStream', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('emits parsed events and calls onDone for a full SSE frame', async () => {
const enc = new TextEncoder();
const frame =
'event: token\ndata: {"content":"hi"}\n\n' +
'event: message_done\ndata: {"runId":"r1","content":"hi"}\n\n';
const fetchMock = vi.fn().mockResolvedValue(okResponse([enc.encode(frame)]));
vi.stubGlobal('fetch', fetchMock);
const onEvent = vi.fn();
const onDone = vi.fn();
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone });
expect(onEvent).toHaveBeenCalledWith('token', { content: 'hi' });
expect(onEvent).toHaveBeenCalledWith('message_done', { runId: 'r1', content: 'hi' });
expect(onDone).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/ai/sessions/s1/messages',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ content: 'hi' }) }),
);
});
it('buffers a frame split across chunks', async () => {
const enc = new TextEncoder();
const bytes = enc.encode('event: token\ndata: {"content":"hi"}\n\n');
const half = Math.floor(bytes.length / 2);
const fetchMock = vi.fn().mockResolvedValue(okResponse([bytes.slice(0, half), bytes.slice(half)]));
vi.stubGlobal('fetch', fetchMock);
const onEvent = vi.fn();
const onDone = vi.fn();
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone });
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent).toHaveBeenCalledWith('token', { content: 'hi' });
expect(onDone).toHaveBeenCalledTimes(1);
});
it('skips malformed data payloads without breaking the stream', async () => {
const enc = new TextEncoder();
const bytes = enc.encode(
'event: token\ndata: {not json}\n\nevent: message_done\ndata: {"runId":"r1","content":"done"}\n\n',
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse([bytes])));
const onEvent = vi.fn();
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent });
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent).toHaveBeenCalledWith('message_done', { runId: 'r1', content: 'done' });
});
it('refreshes once on a pre-first-byte 401 and retries the stream', async () => {
const enc = new TextEncoder();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
errorResponse(401, { error: { message: 'Unauthorized', code: 'UNAUTHORIZED', requestId: 'r-1' } }),
)
.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn() })
.mockResolvedValueOnce(okResponse([enc.encode('event: token\ndata: {"content":"ok"}\n\n')]));
vi.stubGlobal('fetch', fetchMock);
const onEvent = vi.fn();
await postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(String(fetchMock.mock.calls[1]?.[0])).toBe('/api/v1/auth/refresh');
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: 'POST' }));
expect(onEvent).toHaveBeenCalledWith('token', { content: 'ok' });
});
it('throws a Session expired error when refresh fails after a 401', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
errorResponse(401, { error: { message: 'Unauthorized', code: 'UNAUTHORIZED' } }),
)
.mockResolvedValueOnce({ ok: false, status: 401, json: vi.fn() });
vi.stubGlobal('fetch', fetchMock);
await expect(
postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent: vi.fn() }),
).rejects.toMatchObject({ code: 'UNAUTHORIZED', status: 401, message: 'Session expired' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws an ApiError when the response is not ok and not 401', async () => {
const fetchMock = vi.fn().mockResolvedValue(
errorResponse(500, { error: { message: 'Boom', code: 'INTERNAL' } }),
);
vi.stubGlobal('fetch', fetchMock);
const onEvent = vi.fn();
const onDone = vi.fn();
await expect(
postEventStream('/ai/sessions/s1/messages', { content: 'hi' }, { onEvent, onDone }),
).rejects.toMatchObject({ code: 'INTERNAL', status: 500, message: 'Boom' });
expect(onEvent).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('stops silently when the stream is aborted', async () => {
const controller = new AbortController();
let rejectRead!: (e: Error) => void;
const reader: MockReader = {
read: vi.fn().mockImplementation(
() => new Promise((_resolve, reject) => { rejectRead = reject; }),
),
releaseLock: vi.fn(),
cancel: vi.fn().mockResolvedValue(undefined),
};
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: { getReader: () => reader },
json: vi.fn(),
}),
);
const onEvent = vi.fn();
const onDone = vi.fn();
const streamPromise = postEventStream(
'/ai/sessions/s1/messages',
{ content: 'hi' },
{ signal: controller.signal, onEvent, onDone },
);
await vi.waitFor(() => expect(reader.read).toHaveBeenCalled());
controller.abort();
rejectRead(new DOMException('Aborted', 'AbortError'));
await streamPromise;
expect(onDone).not.toHaveBeenCalled();
expect(onEvent).not.toHaveBeenCalled();
expect(reader.cancel).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,245 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useAgentConfig,
useAgentRunDetail,
useAgentRuns,
useAdminUsers,
useDisableAgent,
useManualTriageRun,
useMetrics,
useToggleAi,
useUpdateAgentConfig,
useUpdateRole,
useUpdateStatus,
} from './queries';
import { createQueryClient } from '../../test/utils';
vi.mock('../../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}));
import { api } from '../../api/client';
function makeWrapper(qc: QueryClient) {
return function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const defaultWrapper = makeWrapper(createQueryClient());
const user = {
id: 'u1',
email: 'a@b.com',
name: 'A',
role: 'user' as const,
isActive: true,
lastLoginAt: null,
aiEnabled: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
};
const config = {
name: 'triage',
enabled: true,
mode: 'suggest' as const,
allowedFields: ['priority'],
dailyActionCap: 5,
flagThreshold: 'medium' as const,
workingHours: '08:00-18:00',
updatedBy: null,
updatedAt: '2026-01-01T00:00:00Z',
};
const run = {
id: 'r1',
mode: 'copilot' as const,
actorId: null,
status: 'complete' as const,
model: 'gpt-4o',
inputTokens: 10,
outputTokens: 5,
startedAt: '2026-01-01T00:00:00Z',
finishedAt: '2026-01-01T00:00:00Z',
};
const page = { items: [user], page: 1, limit: 20, total: 1 };
describe('admin queries', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches admin users with filters', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
const { result } = renderHook(() => useAdminUsers(1, 20, 'admin', 'alice'), {
wrapper: defaultWrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/admin/users?page=1&limit=20&role=admin&search=alice');
});
it('fetches the agent config', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(config);
const { result } = renderHook(() => useAgentConfig(), { wrapper: defaultWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/admin/agents/triage');
expect(result.current.data?.mode).toBe('suggest');
});
it('updates the agent config', async () => {
(api.patch as ReturnType<typeof vi.fn>).mockResolvedValue({ ...config, mode: 'auto-apply' });
const { result } = renderHook(() => useUpdateAgentConfig(), { wrapper: defaultWrapper });
await act(async () => {
await result.current.mutateAsync({
mode: 'auto-apply',
dailyActionCap: 10,
flagThreshold: 'high',
workingHours: '09:00-17:00',
});
});
expect(api.patch).toHaveBeenCalledWith('/admin/agents/triage/config', {
mode: 'auto-apply',
dailyActionCap: 10,
flagThreshold: 'high',
workingHours: '09:00-17:00',
});
});
it('disables the agent', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: false });
const { result } = renderHook(() => useDisableAgent(), { wrapper: defaultWrapper });
await act(async () => {
await result.current.mutateAsync(undefined);
});
expect(api.post).toHaveBeenCalledWith('/admin/agents/disable');
});
it('runs triage with and without a work order id', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ outcome: 'done' });
const { result } = renderHook(() => useManualTriageRun(), { wrapper: defaultWrapper });
await act(async () => {
await result.current.mutateAsync('wo1');
});
expect(api.post).toHaveBeenCalledWith('/admin/agents/triage/run', { workOrderId: 'wo1' });
await act(async () => {
await result.current.mutateAsync(undefined);
});
expect(api.post).toHaveBeenLastCalledWith('/admin/agents/triage/run', {});
});
it('lists agent runs', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [run],
page: 1,
limit: 20,
total: 1,
});
const { result } = renderHook(() => useAgentRuns(1, 20), { wrapper: defaultWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/admin/agents/runs?page=1&limit=20');
});
it('fetches an agent run detail only when an id is provided', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({ run, messages: [], toolCalls: [] });
const { result } = renderHook(() => useAgentRunDetail('r1'), { wrapper: defaultWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/admin/agents/runs/r1');
renderHook(() => useAgentRunDetail(null), { wrapper: defaultWrapper });
expect(api.get).toHaveBeenCalledTimes(1);
});
it('optimistically toggles aiEnabled in the admin users cache', async () => {
const qc = createQueryClient();
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
qc.setQueryData(key, page);
let resolvePatch!: (v: unknown) => void;
(api.patch as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise((resolve) => { resolvePatch = resolve; }),
);
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
const { result } = renderHook(() => useToggleAi(), { wrapper: makeWrapper(qc) });
let mutatePromise: Promise<unknown> | undefined;
act(() => {
mutatePromise = result.current.mutateAsync({ id: 'u1', aiEnabled: true });
});
await waitFor(() => {
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
expect(data?.items[0]?.aiEnabled).toBe(true);
});
await act(async () => {
resolvePatch(user);
await mutatePromise;
});
expect(api.patch).toHaveBeenCalledWith('/admin/users/u1/ai', { aiEnabled: true });
});
it('optimistically updates a user role and rolls back on error', async () => {
const qc = createQueryClient();
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
qc.setQueryData(key, page);
(api.patch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('nope'));
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
const { result } = renderHook(() => useUpdateRole(), { wrapper: makeWrapper(qc) });
await act(async () => {
await expect(result.current.mutateAsync({ id: 'u1', role: 'admin' })).rejects.toThrow('nope');
});
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
expect(data?.items[0]?.role).toBe('user');
});
it('optimistically updates a user status and rolls back on error', async () => {
const qc = createQueryClient();
const key = ['admin', 'users', { page: 1, limit: 20, role: undefined, search: undefined }];
qc.setQueryData(key, page);
(api.patch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('nope'));
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue(page);
const { result } = renderHook(() => useUpdateStatus(), { wrapper: makeWrapper(qc) });
await act(async () => {
await expect(result.current.mutateAsync({ id: 'u1', isActive: false })).rejects.toThrow('nope');
});
const data = qc.getQueryData<{ items: Array<typeof user> }>(key);
expect(data?.items[0]?.isActive).toBe(true);
});
it('fetches metrics', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({ users: 1, workOrders: 2, uptimeSeconds: 3 });
const { result } = renderHook(() => useMetrics(), { wrapper: defaultWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/admin/metrics');
});
});

View file

@ -0,0 +1,66 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useCopilotSession, useDecideApproval } from './queries';
import { createQueryClient } from '../../test/utils';
vi.mock('../../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}));
import { api } from '../../api/client';
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>;
}
const session = {
id: 's1',
userId: 'u1',
status: 'active' as const,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
};
describe('copilot queries', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('reuses an active session when one exists', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue([
session,
{ ...session, id: 's2', status: 'archived' as const },
]);
const { result } = renderHook(() => useCopilotSession(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.get).toHaveBeenCalledWith('/ai/sessions');
expect(api.post).not.toHaveBeenCalled();
expect(result.current.data?.id).toBe('s1');
});
it('creates a new session when none is active', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue([
{ ...session, id: 'old', status: 'expired' as const },
]);
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue(session);
const { result } = renderHook(() => useCopilotSession(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(api.post).toHaveBeenCalledWith('/ai/sessions');
expect(result.current.data?.id).toBe('s1');
});
it('decides a tool approval', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'tc1', runId: 'r1' });
const { result } = renderHook(() => useDecideApproval(), { wrapper });
await result.current.mutateAsync({ id: 'tc1', approve: true });
expect(api.post).toHaveBeenCalledWith('/ai/tool-calls/tc1/decide', { approve: true });
});
});

View file

@ -0,0 +1,200 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useCopilotStream } from '../features/copilot/useCopilotStream';
const mocks = vi.hoisted(() => ({ postEventStream: vi.fn() }));
vi.mock('../api/stream', () => ({ postEventStream: mocks.postEventStream }));
function streamEvents(events: Array<[string, Record<string, unknown>]>) {
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
for (const [event, data] of events) opts.onEvent(event, data);
opts.onDone?.();
});
}
describe('useCopilotStream', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('appends tokens into one assistant message and finalizes on message_done', async () => {
streamEvents([
['heartbeat', { ts: 1 }],
['token', { content: 'Hel' }],
['token', { content: 'lo' }],
['message_done', { runId: 'r1', content: 'Hello' }],
]);
const { result } = renderHook(() => useCopilotStream());
await act(async () => {
await result.current.send('hi', 's1');
});
expect(result.current.messages).toEqual([
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'Hello' },
]);
expect(result.current.runId).toBe('r1');
expect(result.current.isStreaming).toBe(false);
expect(mocks.postEventStream).toHaveBeenCalledWith(
'/ai/sessions/s1/messages',
{ content: 'hi' },
expect.objectContaining({ onEvent: expect.any(Function), onDone: expect.any(Function) }),
);
});
it('does not append an empty final message', async () => {
streamEvents([
['token', { content: 'Hi' }],
['message_done', { runId: 'r1', content: '' }],
]);
const { result } = renderHook(() => useCopilotStream());
await act(async () => {
await result.current.send('hi', 's1');
});
expect(result.current.messages).toEqual([
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'Hi' },
]);
});
it('exposes a pendingApproval until a tool_result resolves it', async () => {
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
opts.onEvent('tool_approval_required', {
toolCallId: 'tc1',
tool: 'update_work_order',
args: { title: 'x' },
summary: 'Update wo1',
expiresAt: '2026-01-01T00:00:00Z',
});
await Promise.resolve();
opts.onEvent('tool_result', { toolCallId: 'tc1', outcome: 'executed', result: 'ok' });
opts.onDone?.();
});
const { result } = renderHook(() => useCopilotStream());
let sendPromise: Promise<void> | undefined;
act(() => {
sendPromise = result.current.send('hi', 's1');
});
await waitFor(() =>
expect(result.current.pendingApproval).toEqual(
expect.objectContaining({ toolCallId: 'tc1', tool: 'update_work_order', summary: 'Update wo1' }),
),
);
await act(async () => {
await sendPromise;
});
expect(result.current.pendingApproval).toBeNull();
expect(result.current.toolResults).toEqual({ tc1: 'ok' });
});
it('clears a pendingApproval when it expires', async () => {
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
opts.onEvent('tool_approval_required', {
toolCallId: 'tc1',
tool: 'update_work_order',
args: {},
summary: 'Update wo1',
expiresAt: '2026-01-01T00:00:00Z',
});
await Promise.resolve();
opts.onEvent('tool_approval_expired', { toolCallId: 'tc1' });
opts.onDone?.();
});
const { result } = renderHook(() => useCopilotStream());
let sendPromise: Promise<void> | undefined;
act(() => {
sendPromise = result.current.send('hi', 's1');
});
await waitFor(() => expect(result.current.pendingApproval).not.toBeNull());
await act(async () => {
await sendPromise;
});
expect(result.current.pendingApproval).toBeNull();
});
it('does not send while a stream is in flight', async () => {
let resolveStream!: () => void;
mocks.postEventStream.mockImplementation(
() => new Promise<void>((resolve) => { resolveStream = resolve; }),
);
const { result } = renderHook(() => useCopilotStream());
let first: Promise<void> | undefined;
let second: Promise<void> | undefined;
act(() => {
first = result.current.send('one', 's1');
second = result.current.send('two', 's1');
});
await act(async () => {
resolveStream();
});
await first;
await second;
expect(mocks.postEventStream).toHaveBeenCalledTimes(1);
});
it('does not send while an approval is pending', async () => {
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
opts.onEvent('tool_approval_required', {
toolCallId: 'tc1',
tool: 'update_work_order',
args: {},
summary: 'Update wo1',
expiresAt: '2026-01-01T00:00:00Z',
});
opts.onDone?.();
});
const { result } = renderHook(() => useCopilotStream());
await act(async () => {
await result.current.send('hi', 's1');
});
expect(result.current.pendingApproval).not.toBeNull();
await act(async () => {
await result.current.send('again', 's1');
});
expect(mocks.postEventStream).toHaveBeenCalledTimes(1);
});
it('surfaces an error event message', async () => {
streamEvents([['error', { code: 'AI_UNAVAILABLE', message: 'AI provider unavailable' }]]);
const { result } = renderHook(() => useCopilotStream());
await act(async () => {
await result.current.send('hi', 's1');
});
expect(result.current.error).toBe('AI provider unavailable');
expect(result.current.isStreaming).toBe(false);
});
it('sets the error when the stream onError callback fires', async () => {
mocks.postEventStream.mockImplementation(async (_path, _body, opts) => {
opts.onError?.(new Error('stream boom'));
});
const { result } = renderHook(() => useCopilotStream());
await act(async () => {
await result.current.send('hi', 's1');
});
expect(result.current.error).toBe('stream boom');
expect(result.current.isStreaming).toBe(false);
});
});