mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-10-31 18:21:58 +08:00
Follow-up to the contract workstream: the mock handlers, the frontend chat request type (conversationId now optional), the mock-adapter test and the dc3-cli provider/session commands move to the new routes — provider /add|update|delete without the config nesting, POST body pagination for message/list and action/pending. Co-Authored-By: Claude Code <noreply@anthropic.com>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SilentExit } from '../src/utils/format.js';
|
|
import { NetworkError } from '../src/core/errors.js';
|
|
|
|
const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
|
|
|
|
vi.mock('../src/core/config-manager.js', () => ({
|
|
configManager: {
|
|
getActiveProfile: vi.fn(async () => ({ gateway: 'http://gw.test/', tenant: 't', username: 'u', credential_store: 'env' })),
|
|
getActiveProfileName: vi.fn(async () => 'default'),
|
|
load: vi.fn(async () => ({ current_profile: 'default' })),
|
|
getSettings: vi.fn(async () => ({ renewal_threshold_hours: 12, output_format: 'json', color: false })),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../src/core/token-manager.js', () => ({
|
|
tokenManager: {
|
|
getState: vi.fn(async () => null),
|
|
needsRenewal: vi.fn(async () => false),
|
|
},
|
|
}));
|
|
|
|
const { confirmMock } = vi.hoisted(() => ({ confirmMock: vi.fn(async () => false) }));
|
|
vi.mock('../src/utils/prompt.js', () => ({ confirm: confirmMock }));
|
|
|
|
import { Command } from 'commander';
|
|
import { registerSessionCommand, registerActionCommand } from '../src/commands/session.js';
|
|
|
|
function buildProgram(): Command {
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSessionCommand(program);
|
|
registerActionCommand(program);
|
|
return program;
|
|
}
|
|
|
|
async function run(args: string[]): Promise<string> {
|
|
const program = buildProgram();
|
|
let out = '';
|
|
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
out += String(chunk);
|
|
return true;
|
|
});
|
|
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
try {
|
|
try { await program.parseAsync(args, { from: 'user' }); } catch (e) { if (!(e instanceof SilentExit)) throw e; }
|
|
} finally {
|
|
(process.stdout.write as ReturnType<typeof vi.spyOn>).mockRestore();
|
|
(process.exit as ReturnType<typeof vi.spyOn>).mockRestore();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Result of one invocation: captured stdout plus the rejection, when the command failed. */
|
|
interface Capture {
|
|
out: string;
|
|
err?: unknown;
|
|
}
|
|
|
|
async function runCapture(args: string[]): Promise<Capture> {
|
|
const program = buildProgram();
|
|
let out = '';
|
|
let err: unknown;
|
|
const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
out += String(chunk);
|
|
return true;
|
|
});
|
|
try {
|
|
try {
|
|
await program.parseAsync(args, { from: 'user' });
|
|
} catch (e) {
|
|
if (!(e instanceof SilentExit)) err = e;
|
|
}
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
return { out, err };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
confirmMock.mockReset();
|
|
confirmMock.mockResolvedValue(false);
|
|
});
|
|
|
|
describe('session command group', () => {
|
|
beforeEach(() => {
|
|
fetchCalls.length = 0;
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
fetchCalls.push({ url, init: init ?? {} });
|
|
return new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 });
|
|
}));
|
|
});
|
|
|
|
it('list posts paging body to /agentic/session/list', async () => {
|
|
await run(['session', 'list', '--offset', '5', '--limit', '5']);
|
|
expect(fetchCalls[0].url).toBe('http://gw.test/api/v3/agentic/session/list');
|
|
expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({
|
|
offset: 5,
|
|
limit: 5,
|
|
});
|
|
});
|
|
|
|
it('get/messages encode conversation_id as a query param', async () => {
|
|
await run(['session', 'get', 'conv A&B']);
|
|
await run(['session', 'messages', 'conv A&B']);
|
|
expect(fetchCalls[0].url).toBe(
|
|
'http://gw.test/api/v3/agentic/session/get_by_conversation_id?conversation_id=conv%20A%26B',
|
|
);
|
|
expect(fetchCalls[1].url).toContain('/agentic/message/list');
|
|
expect(JSON.parse(String(fetchCalls[1].init.body)).conversationId).toBe('conv A&B');
|
|
});
|
|
|
|
it('rename posts the update body with conversation_id param', async () => {
|
|
await run(['session', 'rename', 'c1', '--name', 'new-name']);
|
|
expect(fetchCalls[0].url).toBe(
|
|
'http://gw.test/api/v3/agentic/session/update?conversation_id=c1',
|
|
);
|
|
expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({ title: 'new-name' });
|
|
});
|
|
|
|
it('delete sends DELETE to the delete route with the id param when --yes skips the gate', async () => {
|
|
await run(['session', 'delete', 'c9', '--yes']);
|
|
expect(fetchCalls[0].init.method).toBe('DELETE');
|
|
expect(fetchCalls[0].url).toContain('/agentic/session/delete?conversation_id=c9');
|
|
expect(confirmMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a declined delete confirmation issues zero requests and reports Cancelled (F045)', async () => {
|
|
confirmMock.mockResolvedValue(false);
|
|
const out = await run(['session', 'delete', 'c9']);
|
|
expect(fetchCalls).toHaveLength(0);
|
|
expect(confirmMock).toHaveBeenCalledTimes(1);
|
|
expect(confirmMock).toHaveBeenCalledWith(expect.stringContaining('"c9"'));
|
|
expect(JSON.parse(out)).toEqual({ ok: true, message: 'Cancelled' });
|
|
});
|
|
|
|
it('an approved delete confirmation proceeds with the request', async () => {
|
|
confirmMock.mockResolvedValue(true);
|
|
await run(['session', 'delete', 'c9']);
|
|
expect(fetchCalls).toHaveLength(1);
|
|
expect(fetchCalls[0].init.method).toBe('DELETE');
|
|
});
|
|
});
|
|
|
|
describe('action approval loop', () => {
|
|
beforeEach(() => {
|
|
fetchCalls.length = 0;
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
fetchCalls.push({ url, init: init ?? {} });
|
|
return new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 });
|
|
}));
|
|
});
|
|
|
|
it('pending scopes by conversation_id', async () => {
|
|
await run(['action', 'pending', '--conversation-id', 'conv-1']);
|
|
expect(fetchCalls[0].url).toBe('http://gw.test/api/v3/agentic/action/pending');
|
|
expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({
|
|
conversationId: 'conv-1',
|
|
offset: 0,
|
|
limit: 20,
|
|
});
|
|
});
|
|
|
|
it('confirm and reject post action_id as query param when --yes skips the gate', async () => {
|
|
await run(['action', 'confirm', 'a-1', '--yes']);
|
|
await run(['action', 'reject', 'a-2', '--yes']);
|
|
expect(fetchCalls[0].init.method).toBe('POST');
|
|
expect(fetchCalls[0].url).toBe('http://gw.test/api/v3/agentic/action/confirm?action_id=a-1');
|
|
expect(fetchCalls[1].init.method).toBe('POST');
|
|
expect(fetchCalls[1].url).toBe('http://gw.test/api/v3/agentic/action/reject?action_id=a-2');
|
|
expect(confirmMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('declined confirm/reject approvals issue zero requests and report Cancelled (F045)', async () => {
|
|
confirmMock.mockResolvedValue(false);
|
|
const confirmed = await run(['action', 'confirm', 'a-1']);
|
|
expect(fetchCalls).toHaveLength(0);
|
|
expect(confirmMock).toHaveBeenCalledWith(expect.stringContaining('Approve'));
|
|
expect(JSON.parse(confirmed)).toEqual({ ok: true, message: 'Cancelled' });
|
|
|
|
const rejected = await run(['action', 'reject', 'a-2']);
|
|
expect(fetchCalls).toHaveLength(0);
|
|
expect(confirmMock).toHaveBeenCalledWith(expect.stringContaining('Reject'));
|
|
expect(JSON.parse(rejected)).toEqual({ ok: true, message: 'Cancelled' });
|
|
});
|
|
|
|
it('an approved confirmation proceeds with the request', async () => {
|
|
confirmMock.mockResolvedValue(true);
|
|
await run(['action', 'confirm', 'a-1']);
|
|
expect(fetchCalls).toHaveLength(1);
|
|
expect(fetchCalls[0].url).toContain('/agentic/action/confirm?action_id=a-1');
|
|
});
|
|
|
|
it('pending forwards offset and limit when supplied', async () => {
|
|
await run(['action', 'pending', '--conversation-id', 'conv-1', '--offset', '10', '--limit', '5']);
|
|
expect(fetchCalls[0].url).toBe('http://gw.test/api/v3/agentic/action/pending');
|
|
expect(JSON.parse(String(fetchCalls[0].init.body))).toEqual({
|
|
conversationId: 'conv-1',
|
|
offset: 10,
|
|
limit: 5,
|
|
});
|
|
});
|
|
|
|
it('pending encodes a hostile conversation_id instead of injecting extra params (F017)', async () => {
|
|
await run(['action', 'pending', '--conversation-id', 'a&b=c#d']);
|
|
const url = new URL(fetchCalls[0].url);
|
|
expect(url.searchParams.get('conversation_id')).toBeNull();
|
|
expect(url.searchParams.get('b')).toBeNull();
|
|
expect(url.searchParams.get('c')).toBeNull();
|
|
expect(JSON.parse(String(fetchCalls[0].init.body)).conversationId).toBe('a&b=c#d');
|
|
});
|
|
|
|
it.each([
|
|
['--offset', 'abc'],
|
|
['--offset', '-5.9'],
|
|
['--offset', '1e9'],
|
|
['--limit', '0x10'],
|
|
['--limit', '2.5'],
|
|
])('pending rejects a garbage %s %s before any request (F021)', async (flag, value) => {
|
|
const { err } = await runCapture(['action', 'pending', '--conversation-id', 'conv-1', flag, value]);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as Error).message).toMatch(/integer/i);
|
|
expect(fetchCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('empty ids are rejected before the gate and the wire (F053 residual)', () => {
|
|
beforeEach(() => {
|
|
fetchCalls.length = 0;
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
fetchCalls.push({ url, init: init ?? {} });
|
|
return new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 });
|
|
}));
|
|
});
|
|
|
|
it.each([
|
|
['session', 'get', ''],
|
|
['session', 'messages', ' '],
|
|
['session', 'rename', '', '--name', 'n'],
|
|
['session', 'delete', '', '--yes'],
|
|
['action', 'confirm', '', '--yes'],
|
|
['action', 'reject', ' ', '--yes'],
|
|
])('%s %s with an empty id throws ValidationError and issues zero requests', async (...args) => {
|
|
const { err } = await runCapture(args);
|
|
|
|
expect(err).toMatchObject({ name: 'ValidationError', kind: 'validation', exitCode: 1 });
|
|
expect((err as Error).message).toMatch(/non-empty/u);
|
|
expect(fetchCalls).toHaveLength(0);
|
|
});
|
|
|
|
it('an empty delete/confirm id fails BEFORE the TTY confirmation prompt', async () => {
|
|
const deleted = await runCapture(['session', 'delete', '']);
|
|
expect(deleted.err).toMatchObject({ name: 'ValidationError' });
|
|
|
|
const confirmed = await runCapture(['action', 'confirm', '']);
|
|
expect(confirmed.err).toMatchObject({ name: 'ValidationError' });
|
|
|
|
// The gate never ran: validation owns the failure, not a declined prompt.
|
|
expect(confirmMock).not.toHaveBeenCalled();
|
|
expect(fetchCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('action failures flow through the typed-error path (F009)', () => {
|
|
beforeEach(() => {
|
|
fetchCalls.length = 0;
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
|
fetchCalls.push({ url, init: init ?? {} });
|
|
throw new TypeError('fetch failed');
|
|
}));
|
|
});
|
|
|
|
it('a transport failure propagates as NetworkError without a second stdout document', async () => {
|
|
const { out, err } = await runCapture(['action', 'confirm', 'a-1', '--yes']);
|
|
|
|
expect(err).toBeInstanceOf(NetworkError);
|
|
// The command itself must not print an error payload: the entry chokepoint
|
|
// owns failure reporting (previously this path printed {ok:false,...} and
|
|
// rewrote the exit code from inside a catch block).
|
|
expect(out).toBe('');
|
|
expect(fetchCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('a declined gate plus a failing gateway still issues zero requests', async () => {
|
|
confirmMock.mockResolvedValue(false);
|
|
const { out, err } = await runCapture(['action', 'reject', 'a-2']);
|
|
expect(err).toBeUndefined();
|
|
expect(out).toContain('Cancelled');
|
|
expect(fetchCalls).toHaveLength(0);
|
|
});
|
|
});
|