423 lines
20 KiB
TypeScript
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)');
|
|
});
|
|
});
|