/* * 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 . */ 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 => ({ 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; type ApiFunction = (...args: unknown[]) => unknown; type TransportCall = { transport: 'httpGet' | 'httpPost' | 'httpPatch' | 'httpDelete' | 'request'; args: unknown[] }; const modules: Record = { 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 = { // 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 }; } if (call.transport === 'httpGet') { return call.args[1] as { params?: Record } | undefined; } if (call.transport === 'httpPost') { return call.args[2] as { params?: Record } | undefined; } return call.args[1] as { params?: Record } | 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(); }); });