Files
lehuo/apps/api/test/case-management.integration-spec.ts
T

491 lines
20 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 {
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<string, StoredObjectMetadata>();
createUploadUrl(
objectKey: string,
mimeType: string,
sha256: string | undefined,
): Promise<string> {
this.metadata.set(objectKey, {
contentLength: 1024,
contentType: mimeType,
sha256: sha256 ?? null,
});
return Promise.resolve(`http://storage.test/upload/${encodeURIComponent(objectKey)}`);
}
createDownloadUrl(objectKey: string): Promise<string> {
return Promise.resolve(`http://storage.test/download/${encodeURIComponent(objectKey)}`);
}
headObject(objectKey: string): Promise<StoredObjectMetadata | null> {
return Promise.resolve(this.metadata.get(objectKey) ?? null);
}
}
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 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<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);
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.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<ApiSuccessResponse<WechatLoginResponse>>(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<ApiSuccessResponse<CreateServiceApplicationResponse>>(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<ApiSuccessResponse<AcceptServiceApplicationResponse>>(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<ApiErrorResponse>(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<ApiSuccessResponse<AcceptServiceApplicationResponse>>(second).data.idempotentReplay,
).toBe(true);
expect(
body<ApiSuccessResponse<AcceptServiceApplicationResponse>>(first).data.serviceCase.publicId,
).toBe(
body<ApiSuccessResponse<AcceptServiceApplicationResponse>>(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<ApiSuccessResponse<BeneficiaryDetailResponse>>(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<ApiSuccessResponse<ServiceCaseDetailResponse>>(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<ApiSuccessResponse<ServiceRecordResponse>>(first).data.record.publicId).toBe(
body<ApiSuccessResponse<ServiceRecordResponse>>(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<ApiSuccessResponse<{ file: { publicId: string } }>>(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);
});
});