import type { INestApplication } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common'; import type { NestExpressApplication } from '@nestjs/platform-express'; 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, CreateServiceApplicationResponse, PaginatedServiceApplicationsResponse, RoleCode, ServiceApplicationDetailResponse, ServiceCategoriesResponse, WechatLoginResponse, } 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 { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter'; import { PrismaService } from '../src/database/prisma.service'; const ADMIN_ORIGIN = 'http://localhost:5173'; let submitIpSequence = 1; class PhaseTwoWechatAdapter implements WechatAuthAdapter { exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { return Promise.resolve({ openid: `phase2-openid-${code}`, unionid: `phase2-unionid-${code}` }); } } function body(response: { body: unknown }): T { return response.body as T; } function adminCookie(response: { headers: Record }): string { const value = response.headers['set-cookie']; const cookies = Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; const cookie = cookies.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`)); if (!cookie) throw new Error('Admin session cookie was not set'); return cookie.split(';')[0] ?? ''; } describe('Phase 2 service applications', () => { let app: INestApplication; let prisma: PrismaService; let passwords: PasswordService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(WECHAT_AUTH_ADAPTER) .useValue(new PhaseTwoWechatAdapter()) .compile(); app = moduleRef.createNestApplication(); (app as NestExpressApplication).set('trust proxy', 1); 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.financialDisclosureStatusHistory.deleteMany(); await prisma.financialDisclosure.deleteMany(); await prisma.notificationDelivery.deleteMany(); await prisma.notification.deleteMany(); await prisma.notificationPreference.deleteMany(); await prisma.contentPostStatusHistory.deleteMany(); await prisma.contentPost.deleteMany(); await prisma.auditLog.deleteMany(); await prisma.activityRegistrationHistory.deleteMany(); await prisma.activityRegistration.deleteMany(); await prisma.activityStatusHistory.deleteMany(); await prisma.activity.deleteMany(); await prisma.serviceCase.updateMany({ data: { projectId: null } }); await prisma.projectStatusHistory.deleteMany(); await prisma.project.deleteMany(); await prisma.serviceApplicationStatusHistory.deleteMany(); await prisma.serviceApplication.deleteMany({ where: { sourceFollowUpTaskId: { not: null } } }); await prisma.followUpStatusHistory.deleteMany(); await prisma.followUpTask.deleteMany(); await prisma.serviceRecordFile.deleteMany(); await prisma.serviceRecordRevision.deleteMany(); await prisma.serviceRecord.deleteMany(); await prisma.file.deleteMany(); await prisma.serviceCaseStatusHistory.deleteMany(); await prisma.caseAssignee.deleteMany(); await prisma.serviceCase.deleteMany(); await prisma.serviceApplicationStatusHistory.deleteMany(); await prisma.serviceApplication.deleteMany(); await prisma.beneficiaryTag.deleteMany(); await prisma.beneficiaryContact.deleteMany(); await prisma.volunteerStatusHistory.deleteMany(); await prisma.volunteerSkill.deleteMany(); await prisma.volunteerInterest.deleteMany(); await prisma.volunteer.deleteMany(); await prisma.consent.deleteMany(); await prisma.beneficiaryProfile.deleteMany(); await prisma.authSession.deleteMany(); await prisma.userRole.deleteMany(); await prisma.adminCredential.deleteMany(); await prisma.staffProfile.deleteMany(); await prisma.wechatIdentity.deleteMany(); await prisma.user.deleteMany(); }); afterAll(async () => { await app.close(); }); async function wechatToken(code: string): Promise { const response = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code }) .expect(201); return body>(response).data.accessToken; } async function createStaff(username: string, roleCode: RoleCode): Promise { const password = 'correct horse battery staple'; const passwordHash = await passwords.hash(password); const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); const user = await prisma.user.create({ data: { publicId: crypto.randomUUID(), userType: 'STAFF', staffProfile: { create: { displayName: '申请测试人员' } }, adminCredential: { create: { username, passwordHash } }, }, }); await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } }); const login = await request(app.getHttpServer() as Server) .post('/api/v1/admin/auth/login') .set('origin', ADMIN_ORIGIN) .send({ username, password }) .expect(201); return adminCookie(login); } function applicationPayload(clientRequestId = crypto.randomUUID()) { return { clientRequestId, serviceCategory: 'OLD_AGE', applyMode: 'SELF', beneficiaryName: '王阿姨', contactMobile: '13812345678', regionCode: '丹阳市开发区', description: '近期行动不便,希望能够得到定期探访和生活协助。', preferredContactTime: '工作日上午', needsHomeVisit: true, mobilityDifficulty: true, privacyConsent: true, }; } async function submit(token: string, payload: Record = applicationPayload()) { return request(app.getHttpServer() as Server) .post('/api/v1/service-applications') .set('authorization', `Bearer ${token}`) .set('x-forwarded-for', `198.51.100.${submitIpSequence++}`) .send(payload); } it('publishes the six approved service categories without authentication', async () => { const response = await request(app.getHttpServer() as Server) .get('/api/v1/service-categories') .expect(200); const result = body>(response).data; expect(result.items).toHaveLength(6); expect(result.items.map((item) => item.code)).toContain('CHILD'); }); it('submits a self application transactionally with encrypted L3 data, consent, history, and audit', async () => { const token = await wechatToken('self-user'); const response = await submit(token).then((result) => { expect(result.status).toBe(201); return result; }); const result = body>(response).data; expect(result.idempotentReplay).toBe(false); expect(result.application.applicationNo).toMatch(/^LH\d{8}[0-9A-F]{10}$/); expect(result.application.status).toBe('SUBMITTED'); expect(result.application.history).toHaveLength(1); const stored = await prisma.serviceApplication.findFirstOrThrow(); expect(stored.beneficiaryNameEncrypted).not.toContain('王阿姨'); expect(stored.contactMobileEncrypted).not.toContain('13812345678'); expect(stored.descriptionEncrypted).not.toContain('定期探访'); expect(await prisma.consent.count()).toBe(1); expect(await prisma.serviceApplicationStatusHistory.count()).toBe(1); expect( await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_SUBMITTED' } }), ).toBe(1); }); it('replays the same idempotency key once and rejects changed content', async () => { const token = await wechatToken('duplicate-user'); const requestId = crypto.randomUUID(); const payload = applicationPayload(requestId); const first = await submit(token, payload).then((result) => { expect(result.status).toBe(201); return result; }); const second = await submit(token, payload).then((result) => { expect(result.status).toBe(201); return result; }); expect( body>(second).data.idempotentReplay, ).toBe(true); expect( body>(second).data.application.publicId, ).toBe( body>(first).data.application.publicId, ); expect(await prisma.serviceApplication.count()).toBe(1); const conflict = await submit(token, { ...payload, description: `${payload.description} 内容已修改`, }); expect(conflict.status).toBe(409); expect(body(conflict).error.code).toBe('DUPLICATE_REQUEST'); }); it('supports a family application and preserves the approved relationship mode', async () => { const token = await wechatToken('family-user'); const response = await submit(token, { ...applicationPayload(), applyMode: 'FAMILY', beneficiaryName: '李奶奶', }).then((result) => { expect(result.status).toBe(201); return result; }); const application = body>(response).data.application; expect(application.applyMode).toBe('FAMILY'); expect(application.applyModeLabel).toBe('帮家人申请'); }); it('lists only the current user applications and denies guessed public identifiers', async () => { const ownerToken = await wechatToken('owner'); const otherToken = await wechatToken('other'); const submitted = await submit(ownerToken).then((response) => { expect(response.status).toBe(201); return body>(response).data.application; }); const mine = await request(app.getHttpServer() as Server) .get('/api/v1/service-applications') .set('authorization', `Bearer ${ownerToken}`) .expect(200); expect(body>(mine).data.total).toBe(1); const others = await request(app.getHttpServer() as Server) .get('/api/v1/service-applications') .set('authorization', `Bearer ${otherToken}`) .expect(200); expect(body>(others).data.total).toBe( 0, ); await request(app.getHttpServer() as Server) .get(`/api/v1/service-applications/${submitted.publicId}`) .set('authorization', `Bearer ${otherToken}`) .expect(403); }); it('rejects missing consent, unknown fields, and invalid contact values', async () => { const token = await wechatToken('validation-user'); const response = await submit(token, { ...applicationPayload(), contactMobile: '12345', privacyConsent: false, internalStatus: 'ACCEPTED', }); expect(response.status).toBe(400); expect(body(response).error.code).toBe('VALIDATION_ERROR'); expect(await prisma.serviceApplication.count()).toBe(0); }); it('allows staff to filter masked lists and audits full detail access', async () => { const token = await wechatToken('admin-visible-owner'); const submitted = await submit(token).then((response) => { expect(response.status).toBe(201); return body>(response).data.application; }); const cookie = await createStaff('phase2.staff', 'STAFF'); const list = await request(app.getHttpServer() as Server) .get('/api/v1/admin/service-applications?serviceCategory=OLD_AGE&status=SUBMITTED') .set('cookie', cookie) .expect(200); const listData = body>(list).data; expect(listData.total).toBe(1); expect(listData.items[0]?.contactMobileMasked).toBe('138****5678'); expect(JSON.stringify(listData)).not.toContain('13812345678'); const detail = await request(app.getHttpServer() as Server) .get(`/api/v1/admin/service-applications/${submitted.publicId}`) .set('cookie', cookie) .expect(200); expect( body>(detail).data.application .contactMobile, ).toBe('13812345678'); expect( await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_DETAIL_VIEWED' } }), ).toBe(1); }); it('denies the admin application list to a role without application read permission', async () => { const cookie = await createStaff('phase2.volunteer', 'VOLUNTEER'); const response = await request(app.getHttpServer() as Server) .get('/api/v1/admin/service-applications') .set('cookie', cookie) .expect(403); expect(body(response).error.code).toBe('FORBIDDEN'); }); });