import { ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import { getStorageToken, type ThrottlerStorageService } from '@nestjs/throttler'; import type { Server } from 'node:http'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import type { AcceptServiceApplicationResponse, ApiErrorResponse, ApiSuccessResponse, BeneficiaryDetailResponse, CreateServiceApplicationResponse, RoleCode, ServiceCaseDetailResponse, ServiceRecordResponse, 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'; import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter, type StoredObjectMetadata, } from '../src/files/object-storage.adapter'; const ADMIN_ORIGIN = 'http://localhost:5173'; class PhaseThreeWechatAdapter implements WechatAuthAdapter { exchangeCode(code: string): Promise<{ openid: string; unionid: string }> { return Promise.resolve({ openid: `phase3-openid-${code}`, unionid: `phase3-unionid-${code}` }); } } class TestObjectStorageAdapter implements ObjectStorageAdapter { readonly configured = true; readonly providerName = 'TEST_S3'; readonly bucket = 'test-private'; private readonly metadata = new Map(); createUploadUrl( objectKey: string, mimeType: string, sha256: string | undefined, ): Promise { this.metadata.set(objectKey, { contentLength: 1024, contentType: mimeType, sha256: sha256 ?? null, }); return Promise.resolve(`http://storage.test/upload/${encodeURIComponent(objectKey)}`); } createDownloadUrl(objectKey: string): Promise { return Promise.resolve(`http://storage.test/download/${encodeURIComponent(objectKey)}`); } headObject(objectKey: string): Promise { return Promise.resolve(this.metadata.get(objectKey) ?? null); } } 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 3 beneficiary and case management', () => { let app: INestApplication; let prisma: PrismaService; let passwords: PasswordService; let throttlerStorage: ThrottlerStorageService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(WECHAT_AUTH_ADAPTER) .useValue(new PhaseThreeWechatAdapter()) .overrideProvider(OBJECT_STORAGE_ADAPTER) .useValue(new TestObjectStorageAdapter()) .compile(); app = moduleRef.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); throttlerStorage = app.get(getStorageToken()); }); 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(); throttlerStorage.storage.clear(); 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 () => { if (app) await app.close(); }); async function createStaff(username: string, roleCode: RoleCode, displayName = '工作人员') { const password = 'correct horse battery staple'; 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 passwords.hash(password) } }, }, }); 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 { user, cookie: adminCookie(login) }; } async function wechatUser(code: string) { const response = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code }) .expect(201); const result = body>(response).data; const user = await prisma.user.findUniqueOrThrow({ where: { publicId: result.user.publicId } }); return { user, token: result.accessToken }; } async function submitApplication(token: string) { const response = await request(app.getHttpServer() as Server) .post('/api/v1/service-applications') .set('authorization', `Bearer ${token}`) .send({ clientRequestId: crypto.randomUUID(), serviceCategory: 'OLD_AGE', applyMode: 'FAMILY', beneficiaryName: '李奶奶', contactMobile: '13812345678', regionCode: '丹阳市开发区', description: '老人近期行动不便,需要安排定期探访和生活协助。', needsHomeVisit: true, mobilityDifficulty: true, privacyConsent: true, }) .expect(201); return body>(response).data.application; } async function advanceToAssessing(publicId: string, cookie: string) { await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${publicId}/transition`) .set('cookie', cookie) .send({ targetStatus: 'PENDING_CONTACT', reasonCode: 'CONTACT_STARTED' }) .expect(201); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${publicId}/transition`) .set('cookie', cookie) .send({ targetStatus: 'ASSESSING', reasonCode: 'CONTACT_COMPLETED' }) .expect(201); } function acceptance(ownerPublicId: string, clientRequestId = crypto.randomUUID()) { return { clientRequestId, beneficiary: { mode: 'CREATE', profile: { displayName: '李奶奶', gender: 'FEMALE', birthYear: 1948, mobile: '13812345678', regionCode: '丹阳市开发区', keyCareFlag: true, isMinor: false, contacts: [ { name: '李女士', relation: '女儿', mobile: '13912345678', isGuardian: false, isEmergencyContact: true, }, ], tagCodes: ['ADVANCED_AGE', 'MOBILITY_LIMITED'], }, }, ownerStaffPublicId: ownerPublicId, collaboratorUserPublicIds: [], priority: 'HIGH', plannedStartAt: '2026-08-12T02:00:00.000Z', serviceLocation: '丹阳市开发区某小区(详细地址)', summary: '安排工作人员上门探访,了解生活协助需求并制定服务计划。', }; } async function createAcceptedCase() { const applicant = await wechatUser(`applicant-${crypto.randomUUID()}`); const application = await submitApplication(applicant.token); const processor = await createStaff(`processor.${crypto.randomUUID()}`, 'STAFF', '受理人员'); const owner = await createStaff(`owner.${crypto.randomUUID()}`, 'STAFF', '责任人员'); await advanceToAssessing(application.publicId, processor.cookie); const response = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(acceptance(owner.user.publicId)) .expect(201); return { applicant, application, processor, owner, serviceCase: body>(response).data.serviceCase, }; } it('enforces the application state machine and rejects skipped acceptance', async () => { const applicant = await wechatUser('skip-applicant'); const application = await submitApplication(applicant.token); const processor = await createStaff('skip.processor', 'STAFF'); const owner = await createStaff('skip.owner', 'STAFF'); const response = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(acceptance(owner.user.publicId)) .expect(409); expect(body(response).error.code).toBe('INVALID_STATE_TRANSITION'); expect(await prisma.serviceCase.count()).toBe(0); }); it('transactionally accepts, creates an encrypted beneficiary and assigned case with histories', async () => { const result = await createAcceptedCase(); expect(result.serviceCase.status).toBe('ASSIGNED'); expect(result.serviceCase.beneficiaryDisplayName).toBe('李奶奶'); const stored = await prisma.beneficiaryProfile.findFirstOrThrow(); expect(stored.displayNameEncrypted).not.toContain('李奶奶'); expect(stored.mobileEncrypted).not.toContain('13812345678'); expect((await prisma.serviceApplication.findFirstOrThrow()).status).toBe('SERVICE_PLANNED'); expect(await prisma.serviceApplicationStatusHistory.count()).toBe(5); expect(await prisma.serviceCaseStatusHistory.count()).toBe(1); expect(await prisma.caseAssignee.count()).toBe(1); expect(await prisma.auditLog.count({ where: { action: 'SERVICE_CASE_CREATED' } })).toBe(1); }); it('replays identical acceptance and rejects a changed payload', async () => { const applicant = await wechatUser('idempotent-applicant'); const application = await submitApplication(applicant.token); const processor = await createStaff('idempotent.processor', 'STAFF'); const owner = await createStaff('idempotent.owner', 'STAFF'); await advanceToAssessing(application.publicId, processor.cookie); const requestId = crypto.randomUUID(); const payload = acceptance(owner.user.publicId, requestId); const first = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(payload) .expect(201); const second = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(payload) .expect(201); expect( body>(second).data.idempotentReplay, ).toBe(true); expect( body>(first).data.serviceCase.publicId, ).toBe( body>(second).data.serviceCase.publicId, ); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send({ ...payload, priority: 'LOW' }) .expect(409); expect(await prisma.serviceCase.count()).toBe(1); }); it('requires guardian consent for a minor profile', async () => { const applicant = await wechatUser('minor-applicant'); const application = await submitApplication(applicant.token); const processor = await createStaff('minor.processor', 'STAFF'); const owner = await createStaff('minor.owner', 'STAFF'); await advanceToAssessing(application.publicId, processor.cookie); const payload = acceptance(owner.user.publicId); payload.beneficiary.profile.isMinor = true; payload.beneficiary.profile.birthYear = 2016; await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(payload) .expect(400); payload.beneficiary.profile.contacts[0] = { name: '李女士', relation: '母亲', mobile: '13912345678', isGuardian: true, isEmergencyContact: true, }; Object.assign(payload.beneficiary.profile, { guardianConsentConfirmed: true }); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-applications/${application.publicId}/accept`) .set('cookie', processor.cookie) .send(payload) .expect(201); expect(await prisma.consent.count({ where: { consentType: 'BENEFICIARY_GUARDIAN' } })).toBe(1); }); it('audits beneficiary detail and keeps list mobile values masked', async () => { const result = await createAcceptedCase(); const list = await request(app.getHttpServer() as Server) .get('/api/v1/admin/beneficiaries') .set('cookie', result.processor.cookie) .expect(200); expect(JSON.stringify(list.body)).not.toContain('13812345678'); const detail = await request(app.getHttpServer() as Server) .get(`/api/v1/admin/beneficiaries/${result.serviceCase.beneficiaryPublicId}`) .set('cookie', result.processor.cookie) .expect(200); expect( body>(detail).data.beneficiary.mobile, ).toBe('13812345678'); expect(await prisma.auditLog.count({ where: { action: 'BENEFICIARY_DETAIL_VIEWED' } })).toBe(1); }); it('limits cases to assigned staff while project managers can access the full range', async () => { const result = await createAcceptedCase(); const outsider = await createStaff('scope.outsider', 'STAFF'); await request(app.getHttpServer() as Server) .get(`/api/v1/admin/service-cases/${result.serviceCase.publicId}`) .set('cookie', outsider.cookie) .expect(404); const manager = await createStaff('scope.manager', 'PROJECT_MANAGER'); await request(app.getHttpServer() as Server) .get(`/api/v1/admin/service-cases/${result.serviceCase.publicId}`) .set('cookie', manager.cookie) .expect(200); }); it('allows an assigned volunteer to see minimal data and create an idempotent record after start', async () => { const result = await createAcceptedCase(); const volunteer = await wechatUser('assigned-volunteer'); const volunteerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } }); await prisma.userRole.create({ data: { userId: volunteer.user.id, roleId: volunteerRole.id } }); await prisma.caseAssignee.create({ data: { caseId: ( await prisma.serviceCase.findUniqueOrThrow({ where: { publicId: result.serviceCase.publicId }, }) ).id, userId: volunteer.user.id, assigneeType: 'VOLUNTEER', role: 'COLLABORATOR', assignedById: result.processor.user.id, }, }); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${result.serviceCase.publicId}/start`) .set('cookie', result.owner.cookie) .expect(201); const detail = await request(app.getHttpServer() as Server) .get(`/api/v1/service-cases/${result.serviceCase.publicId}`) .set('authorization', `Bearer ${volunteer.token}`) .expect(200); expect( body>(detail).data.serviceCase .beneficiaryMobile, ).toBeNull(); const requestId = crypto.randomUUID(); const recordPayload = { clientRequestId: requestId, serviceStartAt: '2026-08-12T02:00:00.000Z', serviceType: 'HOME_VISIT', content: '上门探访并了解日常生活情况。', result: '已完成本次探访,服务对象情况稳定。', beneficiaryConfirmStatus: 'CONFIRMED', attachmentPublicIds: [], }; const first = await request(app.getHttpServer() as Server) .post(`/api/v1/service-cases/${result.serviceCase.publicId}/records`) .set('authorization', `Bearer ${volunteer.token}`) .send(recordPayload) .expect(201); const second = await request(app.getHttpServer() as Server) .post(`/api/v1/service-cases/${result.serviceCase.publicId}/records`) .set('authorization', `Bearer ${volunteer.token}`) .send(recordPayload) .expect(201); expect(body>(first).data.record.publicId).toBe( body>(second).data.record.publicId, ); expect(await prisma.serviceRecord.count()).toBe(1); }); it('validates image upload metadata and returns only short-lived private URLs', async () => { const result = await createAcceptedCase(); const intent = await request(app.getHttpServer() as Server) .post(`/api/v1/service-cases/${result.serviceCase.publicId}/files/upload-intents`) .set('cookie', result.owner.cookie) .send({ clientRequestId: crypto.randomUUID(), filename: '探访照片.jpg', mimeType: 'image/jpeg', size: 1024, sha256: 'a'.repeat(64), }) .expect(201); const filePublicId = body>(intent).data.file.publicId; await request(app.getHttpServer() as Server) .post(`/api/v1/files/${filePublicId}/confirm`) .set('cookie', result.owner.cookie) .expect(201); const download = await request(app.getHttpServer() as Server) .get(`/api/v1/files/${filePublicId}/download-url`) .set('cookie', result.owner.cookie) .expect(200); expect(JSON.stringify(download.body)).toContain('http://storage.test/download/'); await request(app.getHttpServer() as Server) .post(`/api/v1/service-cases/${result.serviceCase.publicId}/files/upload-intents`) .set('cookie', result.owner.cookie) .send({ clientRequestId: crypto.randomUUID(), filename: 'dangerous.html', mimeType: 'text/html', size: 100, }) .expect(400); }); });