Files
kaidi/frontend/tests/contracts.test.ts
T
Qiufeng 12d78b4c86
Release / release (push) Canceled after 0s
feat: add system operations dashboard
2026-08-19 13:51:48 +08:00

423 lines
20 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import componentsContract from '../contracts/components.json';
import operationsContract from '../contracts/operations.json';
import routesContract from '../contracts/routes.json';
import { parseWorkbenchTarget } from '../src/pages/workbench/workbench-navigation';
import { allRoutes } from '../src/router';
type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
interface ApiOperation {
module: string;
export: string;
args?: unknown[];
}
interface ContractOperation {
operationId: string;
method: HttpMethod;
path: string;
pages: string[];
api?: ApiOperation;
}
interface CapturedOperation {
module: string;
method: HttpMethod;
path: string;
}
const apiRoot = resolve(process.cwd(), 'src/api');
const operations = operationsContract.operations as ContractOperation[];
function propertyName(name: ts.PropertyName | ts.BindingName): string | undefined {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return undefined;
}
function evaluatePath(expression: ts.Expression, constants = new Map<string, string>()): string | undefined {
if (ts.isStringLiteralLike(expression)) return expression.text;
if (ts.isIdentifier(expression)) return constants.get(expression.text);
if (ts.isTemplateExpression(expression)) {
let result = expression.head.text;
for (const span of expression.templateSpans)
result += `${evaluatePath(span.expression, constants) || '{param}'}${span.literal.text}`;
return result;
}
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = evaluatePath(expression.left, constants);
const right = evaluatePath(expression.right, constants);
return left !== undefined && right !== undefined ? left + right : undefined;
}
return undefined;
}
function normalizePath(path: string): string {
const withoutQuery = path.split('?')[0];
const noPrefix = withoutQuery.replace(/^\/api\/v1/, '') || '/';
return `/api/v1${noPrefix}`
.replace(/\/+/g, '/')
.replace(/^\/api\/v1\/archive(?=\/|$)/, '/api/v1/archives')
.replace(/\{[^}]+\}/g, '{param}');
}
function pathMatches(left: string, right: string): boolean {
const leftParts = normalizePath(left).split('/');
const rightParts = normalizePath(right).split('/');
if (leftParts.length !== rightParts.length) return false;
return leftParts.every(
(part, index) => part === rightParts[index] || part === '{param}' || rightParts[index] === '{param}',
);
}
function urlProperty(object: ts.ObjectLiteralExpression): ts.Expression | undefined {
for (const member of object.properties) {
if (!ts.isPropertyAssignment(member)) continue;
if (propertyName(member.name) === 'url') return member.initializer;
}
return undefined;
}
function extractApiOperations(module: string): CapturedOperation[] {
const filePath = resolve(apiRoot, `${module}.ts`);
const source = readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const result: CapturedOperation[] = [];
const constants = new Map<string, string>();
const collectConstants = (node: ts.Node) => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const value = evaluatePath(node.initializer, constants);
if (value !== undefined) constants.set(node.name.text, value);
}
ts.forEachChild(node, collectConstants);
};
collectConstants(sourceFile);
const add = (method: HttpMethod, expression: ts.Expression | undefined) => {
const path = expression && evaluatePath(expression, constants);
if (path) result.push({ module, method, path: normalizePath(path) });
};
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
const method = callee.name.text.toUpperCase() as HttpMethod;
if (['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
const first = node.arguments[0];
if (first && ts.isObjectLiteralExpression(first)) add(method, urlProperty(first));
}
}
if (ts.isIdentifier(callee) && ['page', 'getPage'].includes(callee.text)) add('GET', node.arguments[0]);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return result;
}
function exportedFunctions(module: string): Set<string> {
const source = readFileSync(resolve(apiRoot, `${module}.ts`), 'utf8');
const names = new Set<string>();
const re = /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g;
for (const match of source.matchAll(re)) names.add(match[1]);
return names;
}
function joinRoutePath(parent: string, child: string): string {
if (child.startsWith('/')) return child;
if (!child) return parent || '/';
return `${parent}/${child}`.replace(/\/+/g, '/');
}
function runtimeContractRoutes() {
const result = new Map<string, { path: string; meta: Record<string, unknown> }>();
const walk = (routes: typeof allRoutes, parentPath = '') => {
for (const route of routes) {
const path = joinRoutePath(parentPath, route.path);
if (typeof route.meta?.pageId === 'string') {
result.set(route.meta.pageId, { path, meta: route.meta as Record<string, unknown> });
}
if (route.children) walk(route.children, path);
}
};
walk(allRoutes);
return result;
}
describe('frontend machine contracts', () => {
it('does not append undocumented cache-busting query parameters by default', () => {
const requestSource = readFileSync(resolve(process.cwd(), 'src/utils/request/index.ts'), 'utf8');
expect(requestSource).toContain('joinTime = false');
expect(requestSource).toMatch(/joinTime:\s*false/);
});
it('consumes the stable governance list DTO without database-key normalization', () => {
const governanceApi = readFileSync(resolve(apiRoot, 'governance.ts'), 'utf8');
const settingsPage = readFileSync(resolve(process.cwd(), 'src/pages/governance/SystemSettingsPage.vue'), 'utf8');
expect(governanceApi).toContain('export interface GovernanceRow');
expect(governanceApi).toContain('resource: GovernanceResource');
expect(governanceApi).toContain('roleCodes?: string[]');
expect(governanceApi).not.toContain('Record<string, unknown> &');
expect(settingsPage).not.toContain('function camelKey');
expect(settingsPage).not.toContain('normalizeRows(result.items)');
});
it('uses the canonical JSON POST contract for payment exports', () => {
const paymentApi = readFileSync(resolve(apiRoot, 'payment.ts'), 'utf8');
expect(paymentApi).toContain('request.post<AxiosResponse<Blob>>');
expect(paymentApi).toContain("url: '/payments/exports'");
expect(paymentApi).toContain('data: params');
expect(paymentApi).toContain("responseType: 'blob'");
});
it('declares every PAGE-01 through PAGE-24 exactly once', () => {
const expected = Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
const routePageIds = routesContract.routes.map((route) => route.pageId);
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
expect([...routePageIds].sort()).toEqual([...expected].sort());
expect(new Set(routePageIds).size).toBe(24);
expect([...componentPageIds].sort()).toEqual([...expected].sort());
});
it('points every page contract at a real non-placeholder component', () => {
for (const route of routesContract.routes) {
const componentPath = resolve(process.cwd(), route.component);
expect(existsSync(componentPath), `${route.pageId}: ${route.component}`).toBe(true);
const source = readFileSync(componentPath, 'utf8');
expect(source).not.toContain('PagePlaceholder');
expect(source).not.toContain('业务数据接口正在按总方案逐页接入');
}
});
it('keeps route metadata complete and machine-readable', () => {
for (const route of routesContract.routes) {
expect(route.pageId).toMatch(/^PAGE-\d{2}$/);
expect(route.path).toMatch(/^\//);
expect(route.pageType).toMatch(/^(system|business)$/);
expect(route.pageTemplate).toBeTruthy();
expect(typeof route.requiresAuth).toBe('boolean');
expect(Array.isArray(route.roles)).toBe(true);
expect('permission' in route).toBe(true);
expect(Array.isArray(route.breadcrumb)).toBe(true);
expect(route.description).toBeTruthy();
}
});
it('keeps runtime routes aligned with the route contract', () => {
const runtimeRoutes = runtimeContractRoutes();
expect([...runtimeRoutes.keys()].sort()).toEqual(routesContract.routes.map((route) => route.pageId).sort());
for (const route of routesContract.routes) {
const runtime = runtimeRoutes.get(route.pageId);
expect(runtime?.path, `${route.pageId}: path`).toBe(route.path);
expect(runtime?.meta.pageType, `${route.pageId}: pageType`).toBe(route.pageType);
expect(runtime?.meta.pageTemplate, `${route.pageId}: pageTemplate`).toBe(route.pageTemplate);
expect(runtime?.meta.requiresAuth, `${route.pageId}: requiresAuth`).toBe(route.requiresAuth);
expect(runtime?.meta.roleCodes, `${route.pageId}: roles`).toEqual(route.roles);
expect(runtime?.meta.permission, `${route.pageId}: permission`).toEqual(route.permission);
expect(runtime?.meta.description, `${route.pageId}: description`).toBe(route.description);
expect(runtime?.meta.breadcrumb, `${route.pageId}: breadcrumb`).toEqual(route.breadcrumb);
expect(runtime?.meta.componentPath, `${route.pageId}: component`).toBe(route.component);
}
});
it('requires unique operations with valid page references and API wrappers', () => {
const pageIds = new Set(routesContract.routes.map((route) => route.pageId));
expect(new Set(operations.map((operation) => operation.operationId)).size).toBe(operations.length);
for (const operation of operations) {
expect(operation.path).toMatch(/^\/api\/v1\//);
expect(operation.pages.length).toBeGreaterThan(0);
for (const page of operation.pages) expect(pageIds.has(page), `${operation.operationId}: ${page}`).toBe(true);
expect(operation.api, `${operation.operationId} must identify its wrapper`).toBeTruthy();
if (operation.api) {
expect(existsSync(resolve(apiRoot, `${operation.api.module}.ts`))).toBe(true);
expect(
exportedFunctions(operation.api.module).has(operation.api.export),
`${operation.operationId}: ${operation.api.export}`,
).toBe(true);
}
}
});
it('matches every business API wrapper path and method in both directions', () => {
const observed: CapturedOperation[] = [];
for (const entry of readdirSync(apiRoot)) {
if (!entry.endsWith('.ts')) continue;
const module = entry.slice(0, -3);
observed.push(...extractApiOperations(module));
}
const declared = operations.map((operation) => ({ ...operation, normalizedPath: normalizePath(operation.path) }));
for (const operation of declared) {
const match = observed.some(
(item) => item.method === operation.method && pathMatches(item.path, operation.normalizedPath),
);
expect(match, `${operation.operationId}: ${operation.method} ${operation.path}`).toBe(true);
}
for (const item of observed) {
const match = declared.some(
(operation) => operation.method === item.method && pathMatches(item.path, operation.normalizedPath),
);
expect(match, `unregistered API wrapper: ${item.module} ${item.method} ${item.path}`).toBe(true);
}
});
});
describe('iam public-entry safeguards', () => {
const loginFormSource = readFileSync(resolve(process.cwd(), 'src/pages/login/components/Login.vue'), 'utf8');
const roleSelectSource = readFileSync(resolve(process.cwd(), 'src/pages/role-select/index.vue'), 'utf8');
const permissionSource = readFileSync(resolve(process.cwd(), 'src/permission.ts'), 'utf8');
it('always sends a newly authenticated user to PAGE-02', () => {
expect(loginFormSource).toContain("await router.replace('/role-select')");
expect(loginFormSource).not.toContain('route.query.redirect');
});
it('blocks an invalid mixed administrator and business identity configuration', () => {
expect(roleSelectSource).toContain("role.code === 'SYSTEM_ADMIN'");
expect(roleSelectSource).toContain("'PROJECT_MANAGER', 'FINANCE_MANAGER', 'ARCHIVE_MANAGER'");
expect(roleSelectSource).toContain('账号身份配置冲突,请联系系统管理员处理。');
});
it('clears identity navigation once when a session-expired event is raised', () => {
expect(permissionSource).toContain('let handlingSessionExpiry = false;');
expect(permissionSource).toContain('if (handlingSessionExpiry) return;');
expect(permissionSource).toContain('getPermissionStore().restoreRoutes();');
});
});
describe('workbench target allowlist', () => {
it('keeps approved routes and query keys', () => {
expect(parseWorkbenchTarget('/finance/payments?tab=result&riskCode=BLOCK')).toEqual({
path: '/finance/payments',
query: { tab: 'result', riskCode: 'BLOCK' },
});
expect(parseWorkbenchTarget('/projects/00000000000000000000000001')).toEqual({
path: '/projects/00000000000000000000000001',
});
expect(parseWorkbenchTarget('/finance/payments?projectId=00000000000000000000000001&view=fund-ledger')).toEqual({
path: '/finance/payments',
query: { projectId: '00000000000000000000000001', view: 'fund-ledger' },
});
});
it('drops unknown query keys and rejects unknown paths', () => {
expect(parseWorkbenchTarget('/tasks?view=todo&token=SECRET')).toEqual({
path: '/tasks',
query: { view: 'todo' },
});
expect(parseWorkbenchTarget('https://example.com')).toBeNull();
expect(parseWorkbenchTarget('/projects/not-an-id')).toBeNull();
});
it('keeps the approved task filter snapshot', () => {
expect(parseWorkbenchTarget('/tasks?view=TODO&formType=OA-06&status=PENDING&page=2&size=50')).toEqual({
path: '/tasks',
query: { view: 'TODO', formType: 'OA-06', status: 'PENDING', page: '2', size: '50' },
});
});
it('keeps PAGE-18 workbench targets inside the frozen query contract', () => {
expect(parseWorkbenchTarget('/archives/files?resource=borrows&scanStatus=AVAILABLE')).toEqual({
path: '/archives/files',
query: { resource: 'borrows', scanStatus: 'AVAILABLE' },
});
expect(parseWorkbenchTarget('/archives/files?archiveStatus=FROZEN')).toEqual({
path: '/archives/files',
query: { archiveStatus: 'FROZEN' },
});
});
it('keeps scoped project and archive workbench filters', () => {
expect(parseWorkbenchTarget('/forms?status=RETURNED&createdByMe=true')).toEqual({
path: '/forms',
query: { status: 'RETURNED', createdByMe: 'true' },
});
expect(parseWorkbenchTarget('/archives/projects?view=prepare&completeness=INCOMPLETE')).toEqual({
path: '/archives/projects',
query: { view: 'prepare', completeness: 'INCOMPLETE' },
});
});
it('keeps the approved non-sensitive PAGE-19 filter snapshot', () => {
expect(
parseWorkbenchTarget(
'/reports?reportCode=payment-progress&companyId=C&projectId=P&dateFrom=2026-08-01&dateTo=2026-08-31&page=2&size=50&token=SECRET',
),
).toEqual({
path: '/reports',
query: {
reportCode: 'payment-progress',
companyId: 'C',
projectId: 'P',
dateFrom: '2026-08-01',
dateTo: '2026-08-31',
page: '2',
size: '50',
},
});
});
});
describe('page-19 report snapshot contracts', () => {
const reportsPageSource = readFileSync(resolve(process.cwd(), 'src/pages/reports/ReportsPage.vue'), 'utf8');
const reportsApiSource = readFileSync(resolve(process.cwd(), 'src/api/reports.ts'), 'utf8');
it('uses the applied date filters for drilldown and freezes them for export confirmation', () => {
expect(reportsPageSource).toContain('getReportDrilldown(snapshot.reportCode, rowId, snapshot.filters)');
expect(reportsPageSource).toContain('...snapshot.filters,');
expect(reportsPageSource).toContain(
'const exportDateRange = computed(() => formatDateRange(exportSnapshot.value?.filters));',
);
expect(reportsPageSource).toContain(
'<t-descriptions-item :label="exportDateBasisLabel">{{ exportDateRange }}</t-descriptions-item>',
);
expect(reportsPageSource).toContain("'receipt-invoice': '收款/开票业务日期'");
expect(reportsPageSource).toContain("'workflow-duration': '任务到达日期'");
});
it('keeps export confirmation bound to the prepared applied snapshot', () => {
expect(reportsApiSource).toContain('export interface ReportExportSnapshot');
expect(reportsPageSource).toContain('const exportSnapshot = ref<ReportExportSnapshot | null>(null);');
expect(reportsPageSource).toContain('confirmReportExport(snapshot.reportCode, prepared.exportId)');
expect(reportsPageSource).toContain("throw new Error('导出筛选快照已变化,请重新预检')");
});
it('blocks a single cross-currency monetary aggregate until the API returns currency groups', () => {
expect(reportsPageSource).toContain('const moneySummaryReports = new Set<ReportCode>([');
expect(reportsPageSource).toContain("hasUnsafeCurrencyAggregate.value ? '未按币种分组,已阻断合计'");
});
});
describe('page-08 project detail safeguards', () => {
const projectPageSource = readFileSync(resolve(process.cwd(), 'src/pages/projects/ProjectDetailPage.vue'), 'utf8');
const projectApiSource = readFileSync(resolve(process.cwd(), 'src/api/project.ts'), 'utf8');
it('does not request project data after the active identity loses view permission', () => {
expect(projectPageSource).toContain("if (!userStore.hasPermission('project:project:view'))");
expect(projectPageSource).toContain("errorMessage.value = '当前身份没有查看项目详情的权限'");
expect(projectPageSource).toContain('projectRequestSequence += 1;');
expect(projectPageSource).toContain('resetDrilldowns();');
});
it('normalizes optional arrays and malformed paged subresource responses', () => {
expect(projectPageSource).toContain('allowedActions: Array.isArray(nextProject.allowedActions)');
expect(projectPageSource).toContain('riskFlags: Array.isArray(nextProject.riskFlags)');
expect(projectPageSource).toContain('timeline: Array.isArray(nextProject.timeline)');
expect(projectApiSource).toContain('Array.isArray(envelope?.data) ? envelope.data : []');
expect(projectApiSource).toContain('totalElements: 0, totalPages: 0');
});
});
describe('page-09 to page-11 source workflow response safeguards', () => {
const sourceApiSource = readFileSync(resolve(process.cwd(), 'src/api/source.ts'), 'utf8');
it('normalizes collection and command responses before a TDesign page renders them', () => {
expect(sourceApiSource).toContain('(Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeSummary)');
expect(sourceApiSource).toContain('(Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeTask)');
expect(sourceApiSource).toContain('allowedActions: Array.isArray(summary?.allowedActions)');
expect(sourceApiSource).toContain('allowedActions: Array.isArray(task?.allowedActions)');
expect(sourceApiSource).toContain('.then(normalizeTaskDetail)');
expect(sourceApiSource).toContain('.then(normalizeDetail)');
});
});