381 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|