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 { ApiErrorResponse, ApiSuccessResponse, CompleteServiceCaseResponse, FollowUpMutationResponse, FollowUpWorkbenchSummary, RoleCode, } 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'; import { SensitiveDataService } from '../src/security/sensitive-data.service'; const ADMIN_ORIGIN = 'http://localhost:5173'; 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 4 follow-up closure', () => { let app: INestApplication; let prisma: PrismaService; let passwords: PasswordService; let sensitiveData: SensitiveDataService; let throttlerStorage: ThrottlerStorageService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).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); sensitiveData = app.get(SensitiveDataService); 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.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 createStaff(username: string, roleCode: RoleCode, displayName: string) { 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 createInServiceCase(withRecord: boolean, keyCareFlag = true) { const owner = await createStaff(`owner.${crypto.randomUUID()}`, 'STAFF', '回访负责人'); const applicant = await prisma.user.create({ data: { publicId: crypto.randomUUID(), userType: 'PUBLIC', nickname: '申请人' }, }); const consent = await prisma.consent.create({ data: { publicId: crypto.randomUUID(), userId: applicant.id, consentType: 'SERVICE_APPLICATION', version: 'service-application-v1', grantedBy: 'APPLICANT', grantedAt: new Date(), }, }); const beneficiary = await prisma.beneficiaryProfile.create({ data: { publicId: crypto.randomUUID(), displayNameEncrypted: sensitiveData.encrypt('王奶奶'), displayNameHash: sensitiveData.lookupHash('beneficiary-display-name', '王奶奶'), mobileEncrypted: sensitiveData.encrypt('13812345678'), mobileHash: sensitiveData.lookupHash('mobile', '13812345678'), regionCode: '丹阳市开发区', primaryCategory: 'OLD_AGE', keyCareFlag, isMinor: false, createdById: owner.user.id, updatedById: owner.user.id, }, }); const application = await prisma.serviceApplication.create({ data: { publicId: crypto.randomUUID(), applicationNo: `SQ${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`, applicantUserId: applicant.id, beneficiaryId: beneficiary.id, consentId: consent.id, clientRequestId: crypto.randomUUID(), requestFingerprint: 'a'.repeat(64), applyMode: 'FAMILY', serviceCategory: 'OLD_AGE', beneficiaryNameEncrypted: sensitiveData.encrypt('王奶奶'), contactMobileEncrypted: sensitiveData.encrypt('13812345678'), contactMobileHash: sensitiveData.lookupHash('mobile', '13812345678'), regionCode: '丹阳市开发区', descriptionEncrypted: sensitiveData.encrypt('需要定期探访和生活协助。'), status: 'IN_SERVICE', submittedAt: new Date(), acceptedAt: new Date(), }, }); const serviceCase = await prisma.serviceCase.create({ data: { publicId: crypto.randomUUID(), caseNo: `WO${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`, applicationId: application.id, acceptRequestId: crypto.randomUUID(), acceptRequestFingerprint: 'b'.repeat(64), beneficiaryId: beneficiary.id, ownerStaffId: owner.user.id, category: 'OLD_AGE', status: 'IN_SERVICE', summaryEncrypted: sensitiveData.encrypt('定期探访服务计划'), createdById: owner.user.id, updatedById: owner.user.id, assignees: { create: { userId: owner.user.id, assigneeType: 'STAFF', role: 'OWNER', assignedById: owner.user.id, }, }, }, }); if (withRecord) { await prisma.serviceRecord.create({ data: { publicId: crypto.randomUUID(), caseId: serviceCase.id, beneficiaryId: beneficiary.id, clientRequestId: crypto.randomUUID(), requestFingerprint: 'c'.repeat(64), serviceStartAt: new Date(), serviceType: 'HOME_VISIT', contentEncrypted: sensitiveData.encrypt('完成上门探访'), resultEncrypted: sensitiveData.encrypt('服务对象情况稳定'), createdById: owner.user.id, updatedById: owner.user.id, }, }); } return { owner, applicant, beneficiary, application, serviceCase }; } function completionPayload(requestId = crypto.randomUUID()) { return { clientRequestId: requestId, resultSummary: '本阶段探访服务已经按计划完成。', followUpPlan: '七日后电话回访,了解服务满意度和后续需求。', followUpDueAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }; } it('requires a service record, then transactionally completes the case and creates a follow-up', async () => { const baseline = await createInServiceCase(false); const rejected = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(completionPayload()) .expect(400); expect(body(rejected).error.code).toBe('VALIDATION_ERROR'); expect( (await prisma.serviceCase.findUniqueOrThrow({ where: { id: baseline.serviceCase.id } })) .status, ).toBe('IN_SERVICE'); await prisma.serviceRecord.create({ data: { publicId: crypto.randomUUID(), caseId: baseline.serviceCase.id, beneficiaryId: baseline.beneficiary.id, clientRequestId: crypto.randomUUID(), requestFingerprint: 'd'.repeat(64), serviceStartAt: new Date(), serviceType: 'PHONE_CONTACT', contentEncrypted: sensitiveData.encrypt('完成电话关怀'), resultEncrypted: sensitiveData.encrypt('服务目标达成'), createdById: baseline.owner.user.id, updatedById: baseline.owner.user.id, }, }); const completed = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(completionPayload()) .expect(201); const result = body>(completed).data; expect(result.serviceCaseStatus).toBe('SERVICE_COMPLETED'); expect(result.applicationStatus).toBe('FOLLOW_UP_PENDING'); expect(result.followUp.status).toBe('PENDING'); expect(await prisma.followUpTask.count()).toBe(1); expect(await prisma.followUpStatusHistory.count()).toBe(1); expect(await prisma.auditLog.count({ where: { action: 'SERVICE_CASE_COMPLETED' } })).toBe(1); const stored = await prisma.followUpTask.findFirstOrThrow(); expect(stored.reasonEncrypted).not.toContain('七日后'); }); it('replays identical completion, rejects changed content and scopes the workbench', async () => { const baseline = await createInServiceCase(true); const requestId = crypto.randomUUID(); const payload = completionPayload(requestId); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(payload) .expect(201); const replay = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(payload) .expect(201); expect( body>(replay).data.idempotentReplay, ).toBe(true); await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send({ ...payload, followUpPlan: '不同的回访安排会触发幂等冲突。' }) .expect(409); await prisma.followUpTask.updateMany({ data: { dueAt: new Date(Date.now() - 60 * 60 * 1000) }, }); const outsider = await createStaff(`outsider.${crypto.randomUUID()}`, 'STAFF', '其他人员'); const ownSummary = await request(app.getHttpServer() as Server) .get('/api/v1/admin/follow-ups/summary') .set('cookie', outsider.cookie) .expect(200); expect(body>(ownSummary).data.dueThisWeek).toBe(0); const manager = await createStaff( `manager.${crypto.randomUUID()}`, 'PROJECT_MANAGER', '项目负责人', ); const managerSummary = await request(app.getHttpServer() as Server) .get('/api/v1/admin/follow-ups/summary') .set('cookie', manager.cookie) .expect(200); expect( body>(managerSummary).data.keyCarePending, ).toBe(1); expect(body>(managerSummary).data.overdue).toBe(1); }); it('creates a linked continuation application with independent consent and closes the original chain', async () => { const baseline = await createInServiceCase(true); const completion = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/service-cases/${baseline.serviceCase.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(completionPayload()) .expect(201); const followUp = body>(completion).data.followUp; const completeRequest = { clientRequestId: crypto.randomUUID(), resultCode: 'NEED_MORE_HELP', resultNote: '服务对象反馈仍需进一步的生活协助。', continuationConsentConfirmed: true, continuationDescription: '继续提供为期一个月的生活协助,并重新评估后续服务需求。', }; const response = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/follow-ups/${followUp.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(completeRequest) .expect(201); const result = body>(response).data; expect(result.followUp.status).toBe('COMPLETED'); expect(result.followUp.continuedApplicationPublicId).not.toBeNull(); expect( ( await prisma.serviceApplication.findUniqueOrThrow({ where: { id: baseline.application.id }, }) ).status, ).toBe('CLOSED'); const continued = await prisma.serviceApplication.findFirstOrThrow({ where: { sourceFollowUpTaskId: { not: null } }, include: { consent: true }, }); expect(continued.status).toBe('SUBMITTED'); expect(continued.parentApplicationId).toBe(baseline.application.id); expect(continued.consent.version).toBe('service-continuation-v1'); expect( await prisma.serviceApplicationStatusHistory.count({ where: { applicationId: continued.id }, }), ).toBe(1); const replay = await request(app.getHttpServer() as Server) .post(`/api/v1/admin/follow-ups/${followUp.publicId}/complete`) .set('cookie', baseline.owner.cookie) .send(completeRequest) .expect(201); expect(body>(replay).data.idempotentReplay).toBe( true, ); expect( await prisma.serviceApplication.count({ where: { sourceFollowUpTaskId: { not: null } } }), ).toBe(1); }); });