Files
iot-dc3/dc3-web/tests/api/api-contracts.test.ts
pnoker eae86b58fc refactor(storage)!: unify platform persistence on reactive postgresql
- migrate auth, manager, data, and agentic persistence to R2DBC
- hard cut pagination, facade, gateway, web, and CLI contracts
- remove legacy database and TSDB adapters and document validation gates

BREAKING CHANGE: platform persistence now requires PostgreSQL 18 with TimescaleDB and the new reactive API contracts.
2026-09-01 22:03:16 +08:00

521 lines
18 KiB
TypeScript

/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {readdirSync, statSync} from 'node:fs';
import {join, relative} from 'node:path';
import {afterEach, describe, expect, it, vi} from 'vitest';
import * as agenticApi from '@/api/agentic';
import * as alarmApi from '@/api/alarm';
import * as authApi from '@/api/api';
import * as attributeApi from '@/api/attribute';
import * as alertApi from '@/api/dashboard/alert';
import * as statsApi from '@/api/dashboard/stats';
import * as systemApi from '@/api/dashboard/system';
import * as topologyApi from '@/api/dashboard/topology';
import * as commandApi from '@/api/command';
import * as deviceApi from '@/api/device';
import * as dictionaryApi from '@/api/dictionary';
import * as driverApi from '@/api/driver';
import * as eventApi from '@/api/event';
import * as groupApi from '@/api/group';
import * as infoApi from '@/api/info';
import * as labelApi from '@/api/label';
import * as menuApi from '@/api/menu';
import * as mcpApi from '@/api/mcp';
import * as pointApi from '@/api/point';
import * as profileApi from '@/api/profile';
import * as resourceApi from '@/api/resource';
import * as roleApi from '@/api/role';
import * as rolePrincipalBindApi from '@/api/rolePrincipalBind';
import * as roleResourceBindApi from '@/api/roleResourceBind';
import * as tokenApi from '@/api/token';
import * as userApi from '@/api/user';
import * as identityAuditApi from '@/api/identityAudit';
import * as localCredentialApi from '@/api/localCredential';
import * as principalApi from '@/api/principal';
import * as serviceAccountApi from '@/api/serviceAccount';
import * as tenantMembershipApi from '@/api/tenantMembership';
import {AUTH_HEADERS} from '@/config/constant/common';
import type {AgenticChatCompletionRequest} from '@/config/types';
import {setStorage} from '@/utils/storageUtil';
// Builds a minimal-but-valid streaming request. Only `messages`, `stream`,
// `conversationId` are required by the contract — everything else is optional
// and irrelevant to the wire-format assertions below.
const streamRequest = (overrides: Partial<AgenticChatCompletionRequest> = {}): AgenticChatCompletionRequest => ({
messages: [{role: 'user', content: 'hello'}],
conversationId: 'conversation-test',
stream: true,
...overrides,
});
const apiSpies = vi.hoisted(() => ({
httpGet: vi.fn(() => Promise.resolve({ok: true, data: null})),
httpPost: vi.fn(() => Promise.resolve({ok: true, data: null})),
httpPatch: vi.fn(() => Promise.resolve({ok: true, data: null})),
httpDelete: vi.fn(() => Promise.resolve({ok: true, data: null})),
request: vi.fn(() => Promise.resolve({ok: true, data: null})),
}));
vi.mock('@/api/common', () => ({
httpGet: apiSpies.httpGet,
httpPost: apiSpies.httpPost,
httpPatch: apiSpies.httpPatch,
httpDelete: apiSpies.httpDelete,
crudAdd: (base: string, payload: unknown) => apiSpies.httpPost(`${base}/add`, payload),
crudUpdate: (base: string, payload: unknown) => apiSpies.httpPost(`${base}/update`, payload),
crudDelete: (base: string, id: string) => apiSpies.httpPost(`${base}/delete`, undefined, {params: {id}}),
versionedDelete: (base: string, id: string, version: number) =>
apiSpies.httpDelete(`${base}/delete`, {params: {id, version}}),
crudGetById: (base: string, id: string) => apiSpies.httpGet(`${base}/get_by_id`, {params: {id}}),
crudList: (base: string, query: unknown) => apiSpies.httpPost(`${base}/list`, query),
}));
vi.mock('@/config/axios', () => ({
default: apiSpies.request,
}));
type ApiModule = Record<string, unknown>;
type ApiFunction = (...args: unknown[]) => unknown;
type TransportCall = { transport: 'httpGet' | 'httpPost' | 'httpPatch' | 'httpDelete' | 'request'; args: unknown[] };
const modules: Record<string, ApiModule> = {
agentic: agenticApi,
alarm: alarmApi,
api: authApi,
attribute: attributeApi,
alert: alertApi,
stats: statsApi,
system: systemApi,
topology: topologyApi,
command: commandApi,
device: deviceApi,
dictionary: dictionaryApi,
driver: driverApi,
event: eventApi,
group: groupApi,
info: infoApi,
label: labelApi,
menu: menuApi,
mcp: mcpApi,
point: pointApi,
profile: profileApi,
resource: resourceApi,
role: roleApi,
rolePrincipalBind: rolePrincipalBindApi,
roleResourceBind: roleResourceBindApi,
token: tokenApi,
user: userApi,
identityAudit: identityAuditApi,
localCredential: localCredentialApi,
principal: principalApi,
serviceAccount: serviceAccountApi,
tenantMembership: tenantMembershipApi,
};
const coveredApiSourceFiles = new Set([
'agentic',
'alarm',
'api',
'attribute',
'command',
'dashboard/alert',
'dashboard/stats',
'dashboard/system',
'dashboard/topology',
'device',
'dictionary',
'driver',
'event',
'group',
'info',
'label',
'menu',
'mcp',
'point',
'profile',
'resource',
'role',
'rolePrincipalBind',
'roleResourceBind',
'token',
'user',
'identityAudit',
'localCredential',
'principal',
'serviceAccount',
'tenantMembership',
]);
const apiSourceFileExclusions = new Set(['common', 'dashboard/index', 'factory']);
const excludedExports = new Set(['streamAgenticChatCompletion', 'completeAgenticChatCompletion']);
const pageQuery = {
page: {current: 2, size: 20},
keyword: 'demo',
};
const offsetQuery = {
offset: 20,
limit: 20,
sort: [{field: 'id', direction: 'ASC'}],
};
const payload = {
id: 'payload-1',
name: 'demo',
enableFlag: 'ENABLE',
};
function exportedFunctions(moduleApi: ApiModule) {
return Object.entries(moduleApi).filter(
([name, value]) => typeof value === 'function' && !excludedExports.has(name)
) as Array<[string, ApiFunction]>;
}
// Explicit registry — preferred path. Add new wrappers HERE rather than
// extending the heuristic fallback below; the heuristic exists only for
// legacy modules whose call shapes were already well-covered when the
// registry was introduced. New entries should pass the exact arguments
// the function expects, in declaration order.
const sampleArgsRegistry: Record<string, unknown[]> = {
// Multi-arg or non-pattern shapes that can't be derived from the name.
updateAgenticSession: ['conversation-1', {title: 'Renamed session'}],
uploadAgenticAttachment: ['conversation-1', new File(['demo'], 'demo.txt', {type: 'text/plain'})],
importDevice: [
payload,
new File(['demo'], 'demo.xlsx', {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),
'device-import-idempotency-key',
],
getDeviceImportOperation: ['/api/v3/manager/operations/get_by_id?id=operation-1'],
alertConfirm: ['driver', 'alert-1'],
alertUnconfirm: ['driver', 'alert-1'],
alertBulkConfirm: [[{source: 'driver', id: 'alert-1'}], true],
listPointValueHistory: ['1001', '2002', undefined, 30],
listRoleByPrincipalId: ['principal-1'],
replaceMcpConnectionTools: ['connection-1', ['tool-1', 'tool-2']],
listMcpAudit: [{principalId: 'principal-1', toolId: 'tool-1', status: 'ACTIVE', riskLevel: 'LOW', limit: 20}],
listMcpConnectionTool: ['connection-1'],
registerMcpClient: [payload],
revokeMcpConnection: ['connection-1'],
getDriverInfoByDeviceIdAndAttributeId: ['device-1', 'attribute-1'],
getPointInfoByDeviceIdAndPointId: ['device-1', 'point-1'],
listCommandInfoByDeviceIdAndCommandId: ['device-1', 'command-1'],
listEventInfoByDeviceIdAndEventId: ['device-1', 'event-1'],
listCommandParam: [offsetQuery],
listEventParam: [offsetQuery],
getCommandHistoryByRecordId: ['record-1'],
getEventHistoryByRecordId: ['record-1'],
// Auth wrappers whose enable/disable/reset/check verbs don't match the heuristic.
listIdentityAudit: [
{
principalId: 'principal-1',
action: 'CREATE',
resourceType: 'role',
resourceId: 'res-1',
status: 'SUCCESS',
limit: 20,
},
],
resetLocalCredentialPassword: ['id-1', 'secret-1'],
checkLoginNameAvailable: ['login-1'],
enablePrincipal: ['id-1'],
disablePrincipal: ['id-1'],
enableServiceAccount: ['id-1'],
disableServiceAccount: ['id-1'],
// Page-query callers whose names don't match the heuristic prefixes.
getAgenticSessions: [pageQuery],
alertPage: [pageQuery],
// Array-id callers without `ByIds` suffix.
getPointUnit: [['id-1', 'id-2']],
deleteDriver: ['id-1', 3],
deleteDevice: ['id-1', 3],
deleteProfile: ['id-1', 3],
deletePoint: ['id-1', 3],
deleteCommand: ['id-1', 3],
deleteCommandParam: ['id-1', 3],
deleteEvent: ['id-1', 3],
deleteEventParam: ['id-1', 3],
deleteDriverAttribute: ['id-1', 3],
deletePointAttribute: ['id-1', 3],
deleteCommandAttribute: ['id-1', 3],
deleteEventAttribute: ['id-1', 3],
deleteDriverInfo: ['id-1', 3],
deletePointInfo: ['id-1', 3],
deleteCommandInfo: ['id-1', 3],
deleteEventInfo: ['id-1', 3],
};
function sampleArgs(name: string): unknown[] {
// 1. Explicit registry wins.
if (Object.prototype.hasOwnProperty.call(sampleArgsRegistry, name)) {
return sampleArgsRegistry[name];
}
// 2. Heuristic fallback — most-specific first to avoid short-circuit
// ambiguity (e.g. `listFooByBarId` matches both `/^list[A-Z]/` and
// `/By[A-Z].*Id$/`; the byId case must win because it takes a string,
// not a page query).
if (/ByIds$/.test(name)) return [['id-1', 'id-2']];
if (/ListBy|By[A-Z].*Id$|ByName$/.test(name)) return ['id-1'];
if (/Tree$/.test(name)) return [pageQuery];
if (/^list[A-Z]/.test(name)) return [pageQuery];
if (/List$|Dictionary$/.test(name)) return [pageQuery];
if (/^(add|update|import|upload|read|write)/.test(name) || /Status$/.test(name)) return [payload];
if (/^(delete|get|confirm|reject)/.test(name)) return ['id-1'];
return [];
}
function walkApiSourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) return walkApiSourceFiles(path);
if (!entry.endsWith('.ts')) return [];
return [relative(join(process.cwd(), 'src/api'), path).replace(/\.ts$/, '').replaceAll('\\', '/')];
});
}
function resetTransportSpies() {
apiSpies.httpGet.mockClear();
apiSpies.httpPost.mockClear();
apiSpies.httpPatch.mockClear();
apiSpies.httpDelete.mockClear();
apiSpies.request.mockClear();
}
function transportCalls(): TransportCall[] {
return [
...apiSpies.httpGet.mock.calls.map((args) => ({transport: 'httpGet' as const, args})),
...apiSpies.httpPost.mock.calls.map((args) => ({transport: 'httpPost' as const, args})),
...apiSpies.httpPatch.mock.calls.map((args) => ({transport: 'httpPatch' as const, args})),
...apiSpies.httpDelete.mock.calls.map((args) => ({transport: 'httpDelete' as const, args})),
...apiSpies.request.mock.calls.map((args) => ({transport: 'request' as const, args})),
];
}
function callUrl(call: TransportCall) {
const firstArg = call.args[0];
if (typeof firstArg === 'string') return firstArg;
if (firstArg && typeof firstArg === 'object' && 'url' in firstArg) {
return String((firstArg as { url?: unknown }).url);
}
return '';
}
function callConfig(call: TransportCall) {
if (call.transport === 'request') {
return call.args[0] as { params?: Record<string, unknown> };
}
if (call.transport === 'httpGet') {
return call.args[1] as { params?: Record<string, unknown> } | undefined;
}
if (call.transport === 'httpPost') {
return call.args[2] as { params?: Record<string, unknown> } | undefined;
}
return call.args[1] as { params?: Record<string, unknown> } | undefined;
}
function expectStandardUrl(call: TransportCall) {
const url = callUrl(call);
expect(url).toMatch(/^api\/v3\//);
expect(url).not.toContain('undefined');
expect(url).not.toContain('?');
const segments = url.split('/').filter(Boolean);
const allowsTrailingPathParam = /^api\/v3\/data\/(?:command_history|event_history)\/[^/]+$/.test(url);
const staticSegments = allowsTrailingPathParam ? segments.slice(0, -1) : segments;
for (const segment of staticSegments) {
expect(segment).not.toMatch(/[A-Z-]/);
expect(segment).not.toMatch(/^\{.+\}$/);
expect(segment).not.toMatch(/^(id|ids|name|code|service|[a-z0-9]+_id)$/);
}
}
function expectStandardParams(call: TransportCall) {
const params = callConfig(call)?.params;
if (!params) return;
for (const key of Object.keys(params)) {
expect(key).not.toMatch(/[A-Z-]/);
}
}
describe('API wrapper contracts', () => {
afterEach(() => {
resetTransportSpies();
});
it('includes every public API wrapper source file in the contract matrix', () => {
const sourceFiles = walkApiSourceFiles(join(process.cwd(), 'src/api'))
.filter((file) => !apiSourceFileExclusions.has(file))
.sort();
expect(sourceFiles).toEqual([...coveredApiSourceFiles].sort());
});
for (const [moduleName, moduleApi] of Object.entries(modules)) {
describe(moduleName, () => {
const entries = exportedFunctions(moduleApi);
for (const [exportName, apiFunction] of entries) {
it(`${exportName} maps to its transport contract`, async () => {
await apiFunction(...sampleArgs(exportName));
const calls = transportCalls();
expect(calls).toHaveLength(1);
expectStandardUrl(calls[0]);
expectStandardParams(calls[0]);
expect(calls[0]).toMatchSnapshot();
});
}
it('has every export covered by the contract matrix', () => {
expect(entries.map(([name]) => name).sort()).toMatchSnapshot();
});
});
}
});
describe('agentic streaming contract', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('posts SSE chat requests with auth headers and emits parsed callbacks', async () => {
setStorage(AUTH_HEADERS.TENANT, 'default');
setStorage(AUTH_HEADERS.LOGIN, 'dc3');
setStorage(AUTH_HEADERS.TOKEN, {salt: 'salt', token: 'token'});
const fetchMock = vi.fn(async () => {
const body = [
'data: {"object":"agentic.event","type":"tool","title":"Tool call","detail":"device","name":"searchDevices","phase":"start","status":"running","created":1}',
'',
'data: {"object":"agentic.event","type":"tool","title":"Device page loaded","detail":"OK","name":"searchDevices","phase":"result","status":"success","code":"OK","created":2}',
'',
'data: {"choices":[{"delta":{"reasoning_content":"let me think "}}]}',
'',
'data: {"choices":[{"delta":{"reasoning_content":"about this"}}]}',
'',
'data: {"choices":[{"delta":{"content":"hello "}}]}',
'',
'data: {"choices":[{"delta":{"content":"world"}}]}',
'',
'data: [DONE]',
'',
].join('\n');
return new Response(body, {
status: 200,
headers: {'Content-Type': 'text/event-stream'},
});
});
vi.stubGlobal('fetch', fetchMock);
const onDelta = vi.fn();
const onEvent = vi.fn();
const onReasoning = vi.fn();
const onDone = vi.fn();
await agenticApi.streamAgenticChatCompletion(streamRequest(), {
onDelta,
onEvent,
onReasoning,
onDone,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/v3/agentic/chat/completions',
expect.objectContaining({
method: 'post',
credentials: 'include',
headers: expect.objectContaining({
Accept: 'text/event-stream',
'Content-Type': 'application/json',
[AUTH_HEADERS.TENANT]: 'default',
[AUTH_HEADERS.LOGIN]: 'dc3',
}),
})
);
const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(requestInit.headers).not.toHaveProperty(AUTH_HEADERS.TOKEN);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({type: 'tool', title: 'Tool call', phase: 'start', status: 'running'})
);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({type: 'tool', title: 'Device page loaded', phase: 'result', status: 'success'})
);
expect(onReasoning.mock.calls.map(([chunk]) => chunk)).toEqual(['let me think ', 'about this']);
expect(onDelta.mock.calls.map(([chunk]) => chunk)).toEqual(['hello ', 'world']);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('normalizes failed stream responses and redirects on 401', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('expired', {status: 401}))
);
const onError = vi.fn();
await expect(agenticApi.streamAgenticChatCompletion(streamRequest({messages: []}), {onError})).rejects.toThrow(
'expired'
);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
expect(window.location.hash).toBe('#/login');
});
it('keeps backend SSE error events as structured trace events', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
const body = [
'data: {"object":"agentic.event","type":"error","title":"Request failed","detail":"backend failed","created":1}',
'',
'data: {"choices":[{"delta":{},"finish_reason":"error"}]}',
'',
'data: [DONE]',
'',
].join('\n');
return new Response(body, {
status: 200,
headers: {'Content-Type': 'text/event-stream'},
});
})
);
const onEvent = vi.fn();
const onFinish = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
await agenticApi.streamAgenticChatCompletion(streamRequest(), {onEvent, onFinish, onDone, onError});
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({type: 'error', title: 'Request failed'}));
expect(onFinish).toHaveBeenCalledWith('error');
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
});