feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
ARCHIVE_API_BASE,
archiveErrorText,
normalizeArchiveFileDetail,
normalizeArchivePackage,
} from '../src/api/archive';
describe('archive R4 frontend contracts', () => {
it('uses the documented plural archive API prefix', () => {
expect(ARCHIVE_API_BASE).toBe('/archives');
});
it('normalizes nullable archive response collections and action allowlists', () => {
const packageView = normalizeArchivePackage({
allowedActions: null,
items: [{ allowedActions: null }],
objectSnapshots: null,
actions: undefined,
} as any);
const detail = normalizeArchiveFileDetail({
file: { allowedActions: null },
versions: null,
borrows: [{ allowedActions: undefined }],
} as any);
expect(packageView).toMatchObject({
allowedActions: [],
items: [{ allowedActions: [] }],
objectSnapshots: [],
actions: [],
});
expect(detail).toMatchObject({ file: { allowedActions: [] }, versions: [], borrows: [{ allowedActions: [] }] });
});
it('renders actionable error classes without dropping server field errors', () => {
expect(archiveErrorText({ status: 401 }, '失败')).toContain('登录会话已失效');
expect(archiveErrorText({ status: 403 }, '失败')).toContain('没有访问');
expect(archiveErrorText({ status: 404 }, '失败')).toContain('不存在');
expect(archiveErrorText({ status: 409 }, '失败')).toContain('数据已发生变化');
expect(archiveErrorText({ status: 422, fieldErrors: { dueAt: '日期无效' } }, '失败')).toContain('日期无效');
expect(archiveErrorText({ status: 503 }, '失败')).toContain('服务处理失败');
});
it('keeps downloaded blob URLs alive until the browser consumes them', () => {
const page = readFileSync(new URL('../src/pages/archives/ArchiveFilesPage.vue', import.meta.url), 'utf8');
expect(page).toContain('window.setTimeout(() => URL.revokeObjectURL(url), 60_000)');
});
it('exposes a recoverable workflow for quarantined archive files', () => {
const api = readFileSync(new URL('../src/api/archive.ts', import.meta.url), 'utf8');
const page = readFileSync(new URL('../src/pages/archives/ArchiveFilesPage.vue', import.meta.url), 'utf8');
const rescanPath = '`$' + '{ARCHIVE_API_BASE}/files/$' + '{publicId}/rescan`';
expect(api).toContain('export function rescanArchiveFile');
expect(api).toContain(rescanPath);
expect(api).toContain("commandHeaders('archive-file-rescan'");
expect(page).toContain("row.allowedActions.includes('RESCAN')");
expect(page).toContain('@click="rescanFile(row)"');
expect(page).toContain("fileFilters.scanStatus = 'QUARANTINED'");
expect(page).toContain('文件已通过安全检查并恢复可用');
});
});
+422
View File
@@ -0,0 +1,422 @@
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-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(
'<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)');
});
});
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const sourceApi = readFileSync(resolve(process.cwd(), 'src/api/source.ts'), 'utf8');
const projectApi = readFileSync(resolve(process.cwd(), 'src/api/project.ts'), 'utf8');
const formCenter = readFileSync(resolve(process.cwd(), 'src/pages/forms/FormCenterPage.vue'), 'utf8');
const formEditor = readFileSync(resolve(process.cwd(), 'src/pages/forms/FormEditorPage.vue'), 'utf8');
const taskCenter = readFileSync(resolve(process.cwd(), 'src/pages/tasks/TaskCenterPage.vue'), 'utf8');
const projectDetail = readFileSync(resolve(process.cwd(), 'src/pages/projects/ProjectDetailPage.vue'), 'utf8');
const accountingPage = readFileSync(resolve(process.cwd(), 'src/pages/finance/AccountingPage.vue'), 'utf8');
const projectArchives = readFileSync(resolve(process.cwd(), 'src/pages/archives/ProjectArchivesPage.vue'), 'utf8');
const archiveFiles = readFileSync(resolve(process.cwd(), 'src/pages/archives/ArchiveFilesPage.vue'), 'utf8');
const projectSubresourcePanel = readFileSync(
resolve(process.cwd(), 'src/components/business/project/ProjectSubresourcePanel.vue'),
'utf8',
);
describe('pAGE-07 through PAGE-11 command controls', () => {
it('adds command idempotency headers to source and project writes', () => {
expect(sourceApi).toContain("commandHeaders('source-form-create', idempotencyKey)");
expect(sourceApi).toContain("commandHeaders('source-form-submit', idempotencyKey)");
expect(sourceApi).toContain('commandHeaders(`workflow-task-' + '$' + '{action}`, idempotencyKey)');
expect(sourceApi).toContain("commandHeaders('workflow-instance-withdraw', idempotencyKey)");
expect(sourceApi).toContain("commandHeaders('workflow-instance-void', idempotencyKey)");
expect(sourceApi).toContain("commandHeaders('source-import-create', idempotencyKey)");
expect(sourceApi).toContain("commandHeaders('source-import-confirm', idempotencyKey)");
expect(projectApi).toContain("commandHeaders('project-risk-create', idempotencyKey)");
expect(projectApi).toContain("commandHeaders('project-risk-resolve', idempotencyKey)");
});
it('gates mutations with server allowedActions and re-reads server state', () => {
expect(formCenter).toContain("!row.allowedActions.includes('VALIDATE')");
expect(formCenter).toContain('await loadForms();');
expect(formEditor).toContain("detail.value.summary.allowedActions.includes('SUBMIT')");
expect(formEditor).toContain('await loadDetail();');
expect(taskCenter).toContain('if (!canRunAction(task, nextAction)) return;');
expect(taskCenter).toContain('await getWorkflowTask(task.publicId);');
expect(taskCenter).toContain('task.form.allowedActions.includes(code)');
expect(taskCenter).toContain('await withdrawSourceForm(');
expect(taskCenter).toContain('await voidSourceForm(');
expect(taskCenter).toContain('const detail = await getWorkflowTask(task.publicId);');
expect(taskCenter).not.toContain("opinion: '审批通过'");
expect(projectDetail).toContain("!project.value.allowedActions.includes('CREATE_RISK_FLAG')");
expect(projectDetail).toContain('await loadProject();');
});
it('uses active source template metadata and TDesign controls for projection fields', () => {
expect(sourceApi).toContain('suppliers: Array.isArray(references?.suppliers)');
expect(formEditor).toContain('const active = (await getSourceTemplates()).find');
expect(formEditor).not.toContain('getSourceTemplate(formType.value, 1)');
expect(formEditor).toContain("field.code.toLowerCase().includes('supplier')");
expect(formEditor).toContain("column.dataType === 'ENUM'");
expect(formEditor).toContain("column.dataType === 'DATE'");
expect(formEditor).toContain('detail.value?.fields || template.value?.fields');
expect(formEditor).toContain('<t-select');
expect(formEditor).toContain('<t-date-picker');
});
it('keeps project drill-down rows traceable to their unique source', () => {
expect(projectSubresourcePanel).toContain('#businessNo="{ row }"');
expect(projectSubresourcePanel).toContain("$emit('open-row', row)");
expect(projectDetail).toContain('@open-row="openDrillRow(tab.value, $event)"');
expect(projectDetail).toContain("name: 'FormEditor'");
expect(projectDetail).toContain('query: { from: route.fullPath }');
expect(projectDetail).toContain("openLinkedPage('/finance/payments'");
expect(projectDetail).toContain("openLinkedPage('/finance/accounting'");
expect(projectDetail).toContain("openLinkedPage('/archives/files'");
expect(formEditor).toContain("from.startsWith('/projects/')");
});
it('keeps project accounting and archive drill-down bidirectional across lifecycle states', () => {
expect(projectDetail).toContain("voucherStatus: textValue(row.voucherStatus, '')");
expect(projectDetail).toContain("sortAt: textValue(isVoucher ? row.updatedAt : row.createdAt, '')");
expect(projectDetail).toContain('return rightTime - leftTime;');
expect(projectDetail).toContain('from: route.fullPath');
expect(accountingPage).toContain("name: 'ProjectDetail'");
expect(accountingPage).toContain('function openEventSource(event: AccountingEvent)');
expect(accountingPage).toContain("route.name !== 'Accounting'");
expect(formCenter).toContain("name: 'ProjectDetail'");
expect(projectArchives).toContain('function openSnapshot(snapshot: ArchivePackageObjectSnapshot)');
expect(projectArchives).toContain("ACCOUNTING_VOUCHER: { path: '/finance/accounting'");
expect(archiveFiles).toContain("name: 'ProjectDetail'");
});
});
@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const paymentApi = readFileSync(resolve(process.cwd(), 'src/api/payment.ts'), 'utf8');
const paymentPage = readFileSync(resolve(process.cwd(), 'src/pages/finance/PaymentsPage.vue'), 'utf8');
describe('page-15 payment control exceptions', () => {
it('models warnings and normalizes server-controlled actions', () => {
expect(paymentApi).toContain("result: 'PASS' | 'WARNING' | 'BLOCK'");
expect(paymentApi).toContain("| 'REQUEST_CONTROL_EXCEPTION'");
expect(paymentApi).toContain('controlExceptions: (Array.isArray(detail?.controlExceptions)');
expect(paymentApi).toContain('attachmentExceptions: (Array.isArray(detail?.attachmentExceptions)');
expect(paymentApi).toContain('allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : []');
});
it('exposes request and independent review commands with idempotency headers', () => {
expect(paymentApi).toContain('`/payments/' + '$' + '{paymentPublicId}/control-exceptions`');
expect(paymentApi).toContain(
'`/payments/' + '$' + '{paymentPublicId}/control-exceptions/' + '$' + '{exceptionPublicId}/review`',
);
expect(paymentApi).toContain("commandHeaders('payment-control-exception-request', idempotencyKey)");
expect(paymentApi).toContain("commandHeaders('payment-control-exception-review', idempotencyKey)");
});
it('gates request and review actions with backend allowedActions and optimistic versions', () => {
expect(paymentPage).toContain("detail.value?.payment.allowedActions.includes('REQUEST_CONTROL_EXCEPTION')");
expect(paymentPage).toContain("row.allowedActions.includes('REVIEW')");
expect(paymentPage).toContain('当前附件不涉及申请不可复核,请刷新后重试。');
expect(paymentPage).toContain('paymentVersion: detail.value.payment.version');
expect(paymentPage).toContain('version: row.version');
expect(paymentPage).toContain('await loadDetail(paymentId, true)');
});
it('uses TDesign dialogs, tables, select, date picker and text areas for the workflow', () => {
expect(paymentPage).toContain('aria-label="付款检查例外"');
expect(paymentPage).toContain('v-model:visible="controlExceptionRequestVisible"');
expect(paymentPage).toContain('v-model:visible="controlExceptionReviewVisible"');
expect(paymentPage).toContain('<t-select v-model="controlExceptionRequestData.controlCode"');
expect(paymentPage).toContain('<t-date-picker');
expect(paymentPage).toContain('v-model="controlExceptionReviewOpinion"');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('production frontend hygiene', () => {
it('keeps Starter demonstrations and Mock fixtures outside the production source tree', () => {
const output = execFileSync(process.execPath, ['scripts/check-production-hygiene.mjs'], {
cwd: process.cwd(),
encoding: 'utf8',
});
expect(output).toContain('Production hygiene passed');
});
it('runs the dist scan as part of every release build', () => {
const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
expect(packageJson.scripts.build).toContain('npm run check:dist');
expect(packageJson.scripts['check:dist']).toBe('node scripts/check-production-hygiene.mjs --dist');
});
});
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const receivableApi = readFileSync(resolve(process.cwd(), 'src/api/receivable.ts'), 'utf8');
const page = readFileSync(resolve(process.cwd(), 'src/pages/finance/ReceiptsInvoicesPage.vue'), 'utf8');
describe('oa-08 returned invoice source correction', () => {
it('exposes the source document and version on invoice views', () => {
expect(receivableApi).toContain('sourceDocumentId?: string | null');
expect(receivableApi).toContain('sourceVersionId?: string | null');
});
it('requires source return points and sends them through the review command', () => {
expect(page).toContain('v-model="actionData.returnPoints"');
expect(page).toContain(':options="oa08ReturnPointOptions"');
expect(page).toContain("returnPoints: actionData.decision === 'RETURN' ? actionData.returnPoints : []");
expect(page).toContain('请选择至少一个需要更正的来源字段');
});
it('uses the existing form editor for source correction and hides direct rejection', () => {
expect(page).toContain("name: 'FormEditor'");
expect(page).toContain("params: { formType: 'OA-08', documentId: sourceDocumentId }");
expect(page).toContain('v-if="!sourceReviewInvoice" value="REJECT"');
expect(page).toContain("invoiceDetail.sourceDocumentId && invoiceDetail.status === 'RETURNED'");
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { normalizeAuditExport } from '../src/api/audit';
import { normalizeReportExport } from '../src/api/reports';
describe('reports and governance frontend response contracts', () => {
it('accepts complete report export confirmations only', () => {
expect(
normalizeReportExport({
exportId: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
reportCode: 'project-ledger',
definitionVersion: 'REPORT-DRAFT-V3',
filterHash: 'a'.repeat(64),
status: 'COMPLETED',
estimatedRows: 1,
rowCount: 1,
sha256: 'b'.repeat(64),
fileName: 'report.csv',
content: 'header\\nvalue',
}),
).toMatchObject({ rowCount: 1, fileName: 'report.csv' });
});
it('rejects incomplete report export confirmations before a download can be reported as successful', () => {
expect(() => normalizeReportExport({ exportId: 'export' } as any)).toThrow('报表导出响应缺少');
});
it('rejects incomplete audit export responses before a download can be reported as successful', () => {
expect(() => normalizeAuditExport({ exportId: 'export', rowCount: 1 } as any)).toThrow('文件摘要');
});
});
+146
View File
@@ -0,0 +1,146 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { RouteRecordRaw } from 'vue-router';
import routesContract from '../contracts/routes.json';
import { allRoutes } from '../src/router';
import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-access';
describe('router and TDesign menu contracts', () => {
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
);
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/system.ts'), 'utf8')).toContain(
"{ path: '/:pathMatch(.*)*', redirect: '/result/404' }",
);
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/result.ts'), 'utf8')).toContain("path: '403'");
expect(readFileSync(resolve(process.cwd(), 'src/router/modules/system.ts'), 'utf8')).toContain("path: '/setup'");
});
it('keeps only contracted business routes in the production route tree', () => {
const flattenedPaths: string[] = [];
const collectPaths = (routes: RouteRecordRaw[], parent = '') => {
for (const route of routes) {
const path = route.path.startsWith('/') ? route.path : `${parent}/${route.path}`.replace(/\/+/g, '/');
flattenedPaths.push(path);
if (route.children) collectPaths(route.children, path);
}
};
collectPaths(allRoutes);
expect(flattenedPaths).toContain('/projects/');
expect(flattenedPaths).not.toContain('/dashboard/base');
expect(flattenedPaths).not.toContain('/detail/base');
expect(flattenedPaths).not.toContain('/list/base');
});
it('filters menu leaves with the same role and permission rules used by route access', () => {
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
const financeIdentity = {
currentRole: 'FINANCE_MANAGER',
permissions: ['masterdata:company:view', 'receivable:invoice:view'],
};
const projectRoute = {
path: 'project',
meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'project:project:view' },
} as RouteRecordRaw;
const financeRoute = {
path: 'finance',
meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'workflow:task:view' },
} as RouteRecordRaw;
const receiptRoute = {
path: 'receipts',
meta: { roleCodes: ['FINANCE_MANAGER'], permission: ['receivable:receipt:view', 'receivable:invoice:view'] },
} as RouteRecordRaw;
const accountingRoute = {
path: 'accounting',
meta: { roleCodes: ['FINANCE_MANAGER'], permission: ['accounting:event:view', 'accounting:voucher:view'] },
} as RouteRecordRaw;
expect(canAccess(projectRoute, projectIdentity)).toBe(true);
expect(canAccess(financeRoute, projectIdentity)).toBe(false);
expect(canAccess(receiptRoute, financeIdentity)).toBe(true);
expect(canAccess(accountingRoute, financeIdentity)).toBe(false);
expect(hasRoleAccess(['PROJECT_MANAGER'], 'SYSTEM_ADMIN')).toBe(true);
expect(
canAccess(accountingRoute, {
currentRole: 'SYSTEM_ADMIN',
permissions: [],
}),
).toBe(true);
});
it('allows the isolated system administrator to access every contracted business route', () => {
const contractedRoutes: RouteRecordRaw[] = [];
const collect = (routes: RouteRecordRaw[]) => {
for (const route of routes) {
if (typeof route.meta?.pageId === 'string') contractedRoutes.push(route);
if (route.children) collect(route.children);
}
};
collect(allRoutes);
const businessPageIds = new Set(
routesContract.routes.filter((route) => route.pageType === 'business').map((route) => route.pageId),
);
const businessRoutes = contractedRoutes.filter((route) => businessPageIds.has(String(route.meta?.pageId)));
expect(new Set(businessRoutes.map((route) => route.meta?.pageId))).toEqual(businessPageIds);
expect(
businessRoutes.filter((route) => !canAccess(route, { currentRole: 'SYSTEM_ADMIN', permissions: [] })),
).toEqual([]);
});
it('sends unauthenticated refreshes to login and denied deep links to the 403 result route', () => {
const guardSource = readFileSync(resolve(process.cwd(), 'src/permission.ts'), 'utf8');
const userStoreSource = readFileSync(resolve(process.cwd(), 'src/store/modules/user.ts'), 'utf8');
expect(userStoreSource).toContain("if (!this.authenticated) throw new Error('Session is not authenticated');");
expect(guardSource).toContain("return { path: '/login', query: { redirect: to.fullPath } };");
expect(guardSource).toContain("return '/result/403';");
expect(guardSource).toContain("to.path === '/setup'");
});
it('redirects each visible menu group to its first permitted child', () => {
const routes: RouteRecordRaw[] = [
{
path: '/finance',
children: [
{ path: 'master-data', meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'masterdata:company:view' } },
{ path: 'contracts-costs', meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'contractcost:ledger:view' } },
],
} as RouteRecordRaw,
{
path: '/workbench',
children: [
{ path: 'project', meta: { roleCodes: ['PROJECT_MANAGER'], permission: 'project:project:view' } },
{ path: 'finance', meta: { roleCodes: ['FINANCE_MANAGER'], permission: 'workflow:task:view' } },
],
} as RouteRecordRaw,
];
const projectMenu = filterMenu(routes, {
currentRole: 'PROJECT_MANAGER',
permissions: ['contractcost:ledger:view', 'project:project:view'],
});
const financeGroup = projectMenu.find((route) => route.path === '/finance');
expect(financeGroup?.redirect).toBe('/finance/contracts-costs');
const financeMenu = filterMenu(routes, {
currentRole: 'FINANCE_MANAGER',
permissions: ['workflow:task:view', 'masterdata:company:view'],
});
const workbenchGroup = financeMenu.find((route) => route.path === '/workbench');
expect(workbenchGroup?.redirect).toBe('/workbench/finance');
expect(financeMenu.find((route) => route.path === '/finance')?.redirect).toBe('/finance/master-data');
const superAdminMenu = filterMenu(routes, {
currentRole: 'SYSTEM_ADMIN',
permissions: [],
});
expect(superAdminMenu.find((route) => route.path === '/workbench')?.children).toHaveLength(2);
expect(superAdminMenu.find((route) => route.path === '/finance')?.redirect).toBe('/finance/master-data');
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { hasPermissionAccess } from '../src/store/modules/menu-access';
describe('system administrator permission semantics', () => {
it('treats the isolated system administrator as having every UI permission', () => {
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'project:project:view')).toBe(true);
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'payment:request:approve')).toBe(true);
expect(hasPermissionAccess('SYSTEM_ADMIN', [], 'permission:introduced:after-login')).toBe(true);
});
it('keeps ordinary roles restricted to their explicit permission list', () => {
const permissions = ['project:project:view'];
expect(hasPermissionAccess('PROJECT_MANAGER', permissions, 'project:project:view')).toBe(true);
expect(hasPermissionAccess('PROJECT_MANAGER', permissions, 'payment:request:approve')).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { WorkbenchView } from '../src/api/workbench';
import { normalizeWorkbench } from '../src/api/workbench';
import { parseWorkbenchTarget } from '../src/pages/workbench/workbench-navigation';
describe('workbench R3 contracts', () => {
it('normalizes every optional workbench collection to an array', () => {
expect(
normalizeWorkbench({
title: '资料管理工作台',
archiveItems: null,
activities: undefined,
} as unknown as Partial<WorkbenchView>),
).toMatchObject({
title: '资料管理工作台',
metrics: [],
todos: [],
projects: [],
issues: [],
funds: [],
archiveItems: [],
activities: [],
});
});
it('keeps download drilldowns within the archive file allowlist', () => {
expect(parseWorkbenchTarget('/archives/files')).toEqual({ path: '/archives/files' });
expect(parseWorkbenchTarget('/archives/files?token=SECRET')).toEqual({
path: '/archives/files',
});
});
});