feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user