feat(finance): add financial disclosure publishing

This commit is contained in:
2026-08-13 17:17:33 +08:00
parent 39a2e53e61
commit 65d23eab4a
49 changed files with 2096 additions and 3 deletions
+2
View File
@@ -68,6 +68,8 @@ describe('Phase 1 authentication and authorization', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
@@ -106,6 +106,8 @@ describe('Phase 3 beneficiary and case management', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
@@ -51,6 +51,8 @@ describe('Phase 6 content and notifications', () => {
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
@@ -0,0 +1,156 @@
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ApiErrorResponse,
ApiSuccessResponse,
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
} from '@lehuo/contracts';
import { AppModule } from '../src/app.module';
import { PasswordService } from '../src/auth/password.service';
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
import { PrismaService } from '../src/database/prisma.service';
const ORIGIN = 'http://localhost:5173';
const body = <T>(response: { body: unknown }) => response.body as T;
function sessionCookie(response: { headers: Record<string, unknown> }) {
const values = response.headers['set-cookie'];
const cookies: string[] = Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: [];
const found = cookies.find((value) => value.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!found) throw new Error('no cookie');
return found.split(';')[0]!;
}
describe('financial disclosure publishing', () => {
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
);
await app.init();
prisma = app.get(PrismaService);
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.auditLog.deleteMany();
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.authSession.deleteMany();
await prisma.userRole.deleteMany();
await prisma.adminCredential.deleteMany();
await prisma.staffProfile.deleteMany();
});
afterAll(() => app.close());
async function admin() {
const username = `finance.${crypto.randomUUID()}`;
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
const user = await prisma.user.create({
data: {
publicId: crypto.randomUUID(),
userType: 'STAFF',
staffProfile: { create: { displayName: '财务公开管理员' } },
adminCredential: {
create: { username, passwordHash: await passwords.hash('correct horse battery staple') },
},
},
});
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
const response = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', ORIGIN)
.send({ username, password: 'correct horse battery staple' })
.expect(201);
return sessionCookie(response);
}
it('keeps drafts private, sanitizes rich text, publishes and audits the document', async () => {
const cookie = await admin();
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/financial-disclosures')
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
clientRequestId: crypto.randomUUID(),
title: '2026年度财务公开',
summary: '年度财务公开说明',
richText: '<h2>年度说明</h2><p onclick="alert(1)">公开正文</p><script>alert(1)</script>',
isPinned: true,
sortOrder: 10,
})
.expect(201);
const disclosure =
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
expect(disclosure.richText).not.toMatch(/script|onclick/);
await request(app.getHttpServer() as Server)
.get(`/api/v1/financial-disclosures/${disclosure.publicId}`)
.expect(404);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${disclosure.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const listed = await request(app.getHttpServer() as Server)
.get('/api/v1/financial-disclosures?page=1&pageSize=20')
.expect(200);
expect(
body<ApiSuccessResponse<PaginatedFinancialDisclosuresResponse>>(listed).data.items,
).toHaveLength(1);
expect(await prisma.financialDisclosureStatusHistory.count()).toBe(2);
expect(await prisma.auditLog.count({ where: { resourceType: 'FINANCIAL_DISCLOSURE' } })).toBe(
2,
);
});
it('rejects edits after publishing and hides archived documents', async () => {
const cookie = await admin(),
requestId = crypto.randomUUID();
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/financial-disclosures')
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
clientRequestId: requestId,
title: '专项财务公开',
richText: '<p>专项公开正文内容</p>',
})
.expect(201);
const item =
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const rejected = await request(app.getHttpServer() as Server)
.patch(`/api/v1/admin/financial-disclosures/${item.publicId}`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({
expectedRevision: item.revision + 1,
title: item.title,
richText: '<p>覆盖已发布内容</p>',
})
.expect(409);
expect(body<ApiErrorResponse>(rejected).error.code).toBe('INVALID_STATE_TRANSITION');
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'ARCHIVED' })
.expect(201);
await request(app.getHttpServer() as Server)
.get(`/api/v1/financial-disclosures/${item.publicId}`)
.expect(404);
});
});
@@ -61,6 +61,8 @@ describe('Phase 4 follow-up closure', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
@@ -70,6 +70,8 @@ describe('Phase 5 projects, activities and volunteers', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
throttlerStorage.storage.clear();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
@@ -69,6 +69,8 @@ describe('Phase 2 service applications', () => {
});
beforeEach(async () => {
await prisma.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();