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 | 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(); 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 { const source = readFileSync(resolve(apiRoot, `${module}.ts`), 'utf8'); const names = new Set(); 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 }>(); 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 }); } 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 &'); 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>'); expect(paymentApi).toContain("url: '/payments/exports'"); expect(paymentApi).toContain('data: params'); expect(paymentApi).toContain("responseType: 'blob'"); }); it('declares every PAGE-01 through PAGE-23 exactly once', () => { const expected = Array.from({ length: 23 }, (_, 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(23); 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( '{{ exportDateRange }}', ); 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(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(['); 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)'); }); });