Files
lehuo/apps/api/test/follow-ups.integration-spec.ts

381 lines
16 KiB
TypeScript

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<T>(response: { body: unknown }): T {
return response.body as T;
}
function adminCookie(response: { headers: Record<string, unknown> }): 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<NestExpressApplication>();
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<ThrottlerStorageService>(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<ApiErrorResponse>(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<ApiSuccessResponse<CompleteServiceCaseResponse>>(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<ApiSuccessResponse<CompleteServiceCaseResponse>>(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<ApiSuccessResponse<FollowUpWorkbenchSummary>>(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<ApiSuccessResponse<FollowUpWorkbenchSummary>>(managerSummary).data.keyCarePending,
).toBe(1);
expect(body<ApiSuccessResponse<FollowUpWorkbenchSummary>>(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<ApiSuccessResponse<CompleteServiceCaseResponse>>(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<ApiSuccessResponse<FollowUpMutationResponse>>(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<ApiSuccessResponse<FollowUpMutationResponse>>(replay).data.idempotentReplay).toBe(
true,
);
expect(
await prisma.serviceApplication.count({ where: { sourceFollowUpTaskId: { not: null } } }),
).toBe(1);
});
});