feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
AdminLoginResponse,
ApiErrorResponse,
ApiSuccessResponse,
CurrentUserResponse,
RoleCode,
UpdateUserRolesResponse,
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';
const ADMIN_ORIGIN = 'http://localhost:5173';
class TestWechatAuthAdapter implements WechatAuthAdapter {
exchangeCode(code: string): Promise<{ openid: string; unionid: string }> {
return Promise.resolve({
openid: `generated-openid-${code}`,
unionid: `generated-unionid-${code}`,
});
}
}
function responseBody<T>(response: { body: unknown }): T {
return response.body as T;
}
function sessionCookie(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 1 authentication and authorization', () => {
let app: INestApplication;
let prisma: PrismaService;
let passwords: PasswordService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(WECHAT_AUTH_ADAPTER)
.useValue(new TestWechatAuthAdapter())
.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);
});
beforeEach(async () => {
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
await prisma.contentPostStatusHistory.deleteMany();
await prisma.contentPost.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 () => {
await app.close();
});
async function createStaff(
username: string,
password: string,
roleCode: RoleCode,
): Promise<{ publicId: string }> {
const passwordHash = await passwords.hash(password);
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 prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
return { publicId: user.publicId };
}
async function adminLogin(username: string, password: string, fixedCookie?: string) {
let call = request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', ADMIN_ORIGIN);
if (fixedCookie) call = call.set('cookie', fixedCookie);
return call.send({ username, password });
}
it('keeps health public while protecting current-user endpoints', async () => {
const health = await request(app.getHttpServer() as Server)
.get('/health')
.expect(200);
expect(responseBody<{ status: string }>(health).status).toBe('ok');
const unauthorized = await request(app.getHttpServer() as Server)
.get('/api/v1/me')
.expect(401);
const error = responseBody<ApiErrorResponse>(unauthorized);
expect(error.error.code).toBe('AUTH_REQUIRED');
expect(error.requestId).toBeTruthy();
});
it('uses an HttpOnly cookie, rejects session fixation, and returns no admin token in JSON', async () => {
const password = 'correct horse battery staple';
await createStaff('admin.one', password, 'ADMIN');
const login = await adminLogin(
'admin.one',
password,
`${ADMIN_SESSION_COOKIE}=attacker-fixed`,
).then((response) => {
expect(response.status).toBe(201);
return response;
});
const cookie = sessionCookie(login);
expect(cookie).not.toContain('attacker-fixed');
const cookieHeaders = (login.headers as Record<string, unknown>)['set-cookie'] as string[];
expect(cookieHeaders.join(';')).toContain('HttpOnly');
expect(cookieHeaders.join(';')).toContain('SameSite=Strict');
const payload = responseBody<ApiSuccessResponse<AdminLoginResponse>>(login);
expect(payload.data.user.roles).toContain('ADMIN');
expect(JSON.stringify(payload)).not.toContain('accessToken');
const me = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/auth/me')
.set('cookie', cookie)
.expect(200);
expect(
responseBody<ApiSuccessResponse<CurrentUserResponse>>(me).data.user.permissions,
).toContain('admin:access');
});
it('rejects cross-origin admin login requests', async () => {
await createStaff('admin.origin', 'correct horse battery staple', 'ADMIN');
const response = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', 'https://malicious.example')
.send({ username: 'admin.origin', password: 'correct horse battery staple' })
.expect(403);
expect(responseBody<ApiErrorResponse>(response).error.code).toBe('FORBIDDEN');
});
it('records failed login without password, username, or session data in audit metadata', async () => {
await createStaff('admin.audit', 'correct horse battery staple', 'ADMIN');
const attemptedPassword = 'incorrect password value';
const response = await adminLogin('admin.audit', attemptedPassword);
expect(response.status).toBe(401);
expect(responseBody<ApiErrorResponse>(response).error.code).toBe('INVALID_CREDENTIALS');
const logs = await prisma.auditLog.findMany({ select: { action: true, metadataJson: true } });
const serialized = JSON.stringify(logs);
expect(serialized).not.toContain(attemptedPassword);
expect(serialized).not.toContain('admin.audit');
expect(serialized).toContain('INVALID_CREDENTIALS');
});
it('rejects a temporarily locked administrator account', async () => {
const password = 'correct horse battery staple';
await createStaff('admin.locked', password, 'ADMIN');
await prisma.adminCredential.update({
where: { username: 'admin.locked' },
data: { failedLoginCount: 5, lockedUntil: new Date(Date.now() + 60_000) },
});
const response = await adminLogin('admin.locked', password);
expect(response.status).toBe(429);
expect(responseBody<ApiErrorResponse>(response).error.code).toBe('ACCOUNT_LOCKED');
});
it('enforces RBAC after a valid staff credential login', async () => {
const password = 'correct horse battery staple';
await createStaff('volunteer.login', password, 'VOLUNTEER');
const login = await adminLogin('volunteer.login', password);
expect(login.status).toBe(201);
const cookie = sessionCookie(login);
const response = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/auth/me')
.set('cookie', cookie)
.expect(403);
expect(responseBody<ApiErrorResponse>(response).error.code).toBe('FORBIDDEN');
});
it('encrypts WeChat identity values, hashes tokens, and rotates previous sessions', async () => {
const first = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code: 'same-code' })
.expect(201);
const firstPayload = responseBody<ApiSuccessResponse<WechatLoginResponse>>(first);
const identity = await prisma.wechatIdentity.findFirstOrThrow();
expect(identity.openidCiphertext).not.toContain('generated-openid-same-code');
expect(identity.openidHash).not.toContain('generated-openid-same-code');
const storedSession = await prisma.authSession.findFirstOrThrow();
expect(storedSession.tokenHash).not.toBe(firstPayload.data.accessToken);
const second = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code: 'same-code' })
.expect(201);
const secondPayload = responseBody<ApiSuccessResponse<WechatLoginResponse>>(second);
expect(secondPayload.data.accessToken).not.toBe(firstPayload.data.accessToken);
await request(app.getHttpServer() as Server)
.get('/api/v1/me')
.set('authorization', `Bearer ${firstPayload.data.accessToken}`)
.expect(401);
await request(app.getHttpServer() as Server)
.get('/api/v1/me')
.set('authorization', `Bearer ${secondPayload.data.accessToken}`)
.expect(200);
const activeSession = await prisma.authSession.findFirstOrThrow({ where: { revokedAt: null } });
await prisma.authSession.update({
where: { id: activeSession.id },
data: { expiresAt: new Date(Date.now() - 1_000) },
});
await request(app.getHttpServer() as Server)
.get('/api/v1/me')
.set('authorization', `Bearer ${secondPayload.data.accessToken}`)
.expect(401);
});
it('allows an authorized admin to replace roles and audits the change', async () => {
const password = 'correct horse battery staple';
await createStaff('role.admin', password, 'ADMIN');
const target = await createStaff('role.target', password, 'STAFF');
const login = await adminLogin('role.admin', password);
const cookie = sessionCookie(login);
const response = await request(app.getHttpServer() as Server)
.put(`/api/v1/admin/users/${target.publicId}/roles`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', cookie)
.send({ roles: ['PROJECT_MANAGER'] })
.expect(200);
expect(responseBody<ApiSuccessResponse<UpdateUserRolesResponse>>(response).data.roles).toEqual([
'PROJECT_MANAGER',
]);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: 'RBAC_USER_ROLES_REPLACED' },
});
expect(audit.resourcePublicId).toBe(target.publicId);
});
it('does not allow removal of the last active super administrator', async () => {
const password = 'correct horse battery staple';
const superAdmin = await createStaff('last.super', password, 'SUPER_ADMIN');
const login = await adminLogin('last.super', password);
const cookie = sessionCookie(login);
const response = await request(app.getHttpServer() as Server)
.put(`/api/v1/admin/users/${superAdmin.publicId}/roles`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', cookie)
.send({ roles: ['ADMIN'] })
.expect(409);
expect(responseBody<ApiErrorResponse>(response).error.code).toBe('VALIDATION_ERROR');
});
});
@@ -0,0 +1,488 @@
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.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);
});
});
@@ -0,0 +1,251 @@
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ApiErrorResponse,
ApiSuccessResponse,
ContentPostDetailResponse,
PaginatedContentPostsResponse,
PaginatedNotificationsResponse,
WechatLoginResponse,
StatisticsOverview,
} 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';
const ORIGIN = 'http://localhost:5173';
class Wx implements WechatAuthAdapter {
exchangeCode(code: string) {
return Promise.resolve({ openid: `phase6-${code}`, unionid: `phase6-u-${code}` });
}
}
const body = <T>(r: { body: unknown }) => r.body as T;
function cookie(r: { headers: Record<string, unknown> }) {
const v = r.headers['set-cookie'];
const values: string[] = Array.isArray(v)
? v.filter((item): item is string => typeof item === 'string')
: [];
const c = values.find((item) => item.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (typeof c !== 'string') throw new Error('no cookie');
return c.split(';')[0]!;
}
describe('Phase 6 content and notifications', () => {
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
beforeAll(async () => {
const m = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(WECHAT_AUTH_ADAPTER)
.useValue(new Wx())
.compile();
app = m.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);
});
beforeEach(async () => {
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
await prisma.contentPostStatusHistory.deleteMany();
await prisma.contentPost.deleteMany();
await prisma.authSession.deleteMany();
await prisma.userRole.deleteMany();
await prisma.adminCredential.deleteMany();
await prisma.staffProfile.deleteMany();
await prisma.wechatIdentity.deleteMany();
});
afterAll(() => app.close());
async function admin() {
const username = `phase6.${crypto.randomUUID()}`;
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
const u = await prisma.user.create({
data: {
publicId: crypto.randomUUID(),
userType: 'STAFF',
staffProfile: { create: { displayName: '内容管理员' } },
adminCredential: {
create: {
username,
passwordHash: await passwords.hash('correct horse battery staple'),
},
},
},
});
await prisma.userRole.create({ data: { userId: u.id, roleId: role.id } });
const r = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/auth/login')
.set('origin', ORIGIN)
.send({ username, password: 'correct horse battery staple' })
.expect(201);
return cookie(r);
}
async function token(code: string) {
const r = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code })
.expect(201);
return body<ApiSuccessResponse<WechatLoginResponse>>(r).data.accessToken;
}
it('keeps drafts private and enforces reviewed publishing with immutable history', async () => {
const c = await admin();
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/content')
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
type: 'PUBLIC_WELFARE_NEWS',
title: '社区公益服务动态',
summary: '志愿者与工作人员持续开展社区便民公益服务。',
content: '本周开展长者陪伴、便民咨询和社区活动支持,相关服务记录均已按制度留存。',
})
.expect(201);
const post = body<ApiSuccessResponse<ContentPostDetailResponse>>(created).data.post;
await request(app.getHttpServer() as Server)
.get(`/api/v1/content/${post.publicId}`)
.expect(404);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/content/${post.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
targetStatus: 'PENDING_REVIEW',
note: '内容已完成初审,申请发布。',
})
.expect(201);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/content/${post.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
targetStatus: 'PUBLISHED',
note: '事实与隐私边界核验通过。',
})
.expect(201);
const publicList = await request(app.getHttpServer() as Server)
.get('/api/v1/content?page=1&pageSize=20')
.expect(200);
expect(
body<ApiSuccessResponse<PaginatedContentPostsResponse>>(publicList).data.items,
).toHaveLength(1);
expect(await prisma.contentPostStatusHistory.count()).toBe(3);
});
it('requires explicit privacy review for service stories and rejects unsafe markup', async () => {
const c = await admin();
const unsafe = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/content')
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
type: 'NOTICE',
title: '安全公告',
summary: '这是一条用于验证内容输入安全边界的公告摘要。',
content: '<script>alert(1)</script> 这段内容不得被保存到公开内容中。',
})
.expect(400);
expect(body<ApiErrorResponse>(unsafe).error.code).toBe('VALIDATION_ERROR');
const create = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/content')
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
type: 'SERVICE_STORY',
title: '暖心陪伴服务故事',
summary: '经脱敏整理的社区陪伴服务故事,不包含服务对象身份信息。',
content:
'志愿者在工作人员指导下提供陪伴服务。本文不包含姓名、电话、详细地址或可识别的个案描述。',
})
.expect(201);
const p = body<ApiSuccessResponse<ContentPostDetailResponse>>(create).data.post;
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/content/${p.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', c)
.send({
clientRequestId: crypto.randomUUID(),
targetStatus: 'PENDING_REVIEW',
note: '申请审核。',
})
.expect(201);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/content/${p.publicId}/transition`)
.set('origin', ORIGIN)
.set('cookie', c)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED', note: '准备发布。' })
.expect(400);
});
it('isolates in-app messages by owner and records explicit subscription preference', async () => {
const a = await token('a'),
b = await token('b');
const ua = await prisma.wechatIdentity.findFirstOrThrow({
where: { openidHash: { not: '' } },
orderBy: { createdAt: 'asc' },
});
await prisma.notification.create({
data: {
publicId: crypto.randomUUID(),
userId: ua.userId,
eventKey: 'test-owner',
type: 'APPLICATION_ACCEPTED',
title: '申请已受理',
body: '工作人员将与您联系。',
},
});
const mine = await request(app.getHttpServer() as Server)
.get('/api/v1/me/notifications?page=1&pageSize=20')
.set('authorization', `Bearer ${a}`)
.expect(200);
expect(body<ApiSuccessResponse<PaginatedNotificationsResponse>>(mine).data.items).toHaveLength(
1,
);
const other = await request(app.getHttpServer() as Server)
.get('/api/v1/me/notifications?page=1&pageSize=20')
.set('authorization', `Bearer ${b}`)
.expect(200);
expect(body<ApiSuccessResponse<PaginatedNotificationsResponse>>(other).data.items).toHaveLength(
0,
);
await request(app.getHttpServer() as Server)
.put('/api/v1/me/notifications/preferences')
.set('authorization', `Bearer ${a}`)
.send({ items: [{ type: 'APPLICATION_ACCEPTED', inAppEnabled: true, wechatEnabled: true }] })
.expect(200);
expect((await prisma.notificationPreference.findFirstOrThrow()).authorizedAt).not.toBeNull();
});
it('protects statistics and exports a non-sensitive audited summary', async () => {
const adminCookie = await admin();
const denied = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/statistics/overview')
.expect(401);
expect(body<ApiErrorResponse>(denied).error.code).toBe('AUTH_REQUIRED');
const overview = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/statistics/overview?startDate=2026-01-01&endDate=2026-12-31')
.set('origin', ORIGIN)
.set('cookie', adminCookie)
.expect(200);
expect(
body<ApiSuccessResponse<StatisticsOverview>>(overview).data.applicationCount,
).toBeGreaterThanOrEqual(0);
const exported = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/statistics/export.csv?startDate=2026-01-01&endDate=2026-12-31')
.set('origin', ORIGIN)
.set('cookie', adminCookie)
.expect(200);
expect(exported.text).toContain('申请数量');
expect(exported.text).not.toMatch(/1[3-9]\d{9}/);
expect(await prisma.auditLog.count({ where: { action: 'STATISTICS_EXPORTED' } })).toBe(1);
});
});
@@ -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);
});
});
@@ -0,0 +1,412 @@
import { ValidationPipe } from '@nestjs/common';
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import { getStorageToken, type ThrottlerStorageService } from '@nestjs/throttler';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ActivityDetailResponse,
ActivityRegistrationResponse,
ApiErrorResponse,
ApiSuccessResponse,
ProjectDetailResponse,
RoleCode,
VolunteerProfileResponse,
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';
const ADMIN_ORIGIN = 'http://localhost:5173';
class PhaseFiveWechatAdapter implements WechatAuthAdapter {
exchangeCode(code: string): Promise<{ openid: string; unionid: string }> {
return Promise.resolve({ openid: `phase5-openid-${code}`, unionid: `phase5-unionid-${code}` });
}
}
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 5 projects, activities and volunteers', () => {
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 PhaseFiveWechatAdapter())
.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 () => {
throttlerStorage.storage.clear();
await prisma.auditLog.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
await prisma.contentPostStatusHistory.deleteMany();
await prisma.contentPost.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 () => {
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 wechatToken(code: string): Promise<string> {
const response = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code })
.expect(201);
return body<ApiSuccessResponse<WechatLoginResponse>>(response).data.accessToken;
}
async function applyAndApprove(
token: string,
adminCookieValue: string,
suffix: string,
): Promise<VolunteerProfileResponse> {
const applied = await request(app.getHttpServer() as Server)
.put('/api/v1/me/volunteer')
.set('authorization', `Bearer ${token}`)
.send({
clientRequestId: crypto.randomUUID(),
realName: `志愿者${suffix}`,
mobile: `1390000${suffix.padStart(4, '0')}`,
regionCode: '丹阳市云阳街道',
availabilityNote: '周末上午可以参加服务活动',
skillCodes: ['EVENT_SUPPORT'],
interestCategories: ['OLD_AGE'],
privacyConsent: true,
})
.expect(200);
const profile = body<ApiSuccessResponse<VolunteerProfileResponse>>(applied).data.volunteer!;
const approved = await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/volunteers/${profile.publicId}/review`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', adminCookieValue)
.send({
clientRequestId: crypto.randomUUID(),
targetStatus: 'APPROVED',
note: '资料核验通过',
})
.expect(201);
return {
volunteer:
body<ApiSuccessResponse<{ volunteer: VolunteerProfileResponse['volunteer'] }>>(approved)
.data.volunteer,
};
}
it('completes project, activity, registration, check-in and service-minute state machines', async () => {
const admin = await createStaff('phase5.admin', 'ADMIN', '系统管理员');
const manager = await createStaff('phase5.manager', 'PROJECT_MANAGER', '公益项目负责人');
const volunteerToken = await wechatToken('volunteer-one');
await applyAndApprove(volunteerToken, admin.cookie, '0001');
const projectCreated = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/projects')
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({
clientRequestId: crypto.randomUUID(),
name: '暖心探访计划',
category: 'OLD_AGE',
summary: '组织志愿者为社区高龄老人提供定期探访与陪伴服务。',
content: '项目由社区工作人员协调,以公开活动方式招募已认证志愿者参与陪伴和活动支持。',
managerUserPublicId: manager.user.publicId,
startDate: '2026-08-01',
endDate: '2026-12-31',
targetPeople: 100,
isPublic: true,
sortOrder: 10,
})
.expect(201);
const project = body<ApiSuccessResponse<ProjectDetailResponse>>(projectCreated).data.project;
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/projects/${project.publicId}/transition`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const activityCreated = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/activities')
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({
clientRequestId: crypto.randomUUID(),
projectPublicId: project.publicId,
title: '重阳暖心陪伴活动',
summary: '招募志愿者参与社区长者陪伴和现场活动支持。',
content: '志愿者将在工作人员组织下完成签到、陪伴交流和现场秩序协助等服务内容。',
startAt: '2026-10-18T01:00:00.000Z',
endAt: '2026-10-18T05:00:00.000Z',
registrationDeadline: '2026-10-17T12:00:00.000Z',
locationText: '丹阳市云阳街道社区服务中心',
capacity: 1,
})
.expect(201);
const activity =
body<ApiSuccessResponse<ActivityDetailResponse>>(activityCreated).data.activity;
const reviewRequestId = crypto.randomUUID();
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activities/${activity.publicId}/transition`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
.expect(201);
const registrationId = crypto.randomUUID();
const firstRegistration = await request(app.getHttpServer() as Server)
.post(`/api/v1/activities/${activity.publicId}/registrations`)
.set('authorization', `Bearer ${volunteerToken}`)
.send({ clientRequestId: registrationId })
.expect(201);
const registration =
body<ApiSuccessResponse<ActivityRegistrationResponse>>(firstRegistration).data.registration;
const replay = await request(app.getHttpServer() as Server)
.post(`/api/v1/activities/${activity.publicId}/registrations`)
.set('authorization', `Bearer ${volunteerToken}`)
.send({ clientRequestId: registrationId })
.expect(201);
expect(
body<ApiSuccessResponse<ActivityRegistrationResponse>>(replay).data.idempotentReplay,
).toBe(true);
const secondToken = await wechatToken('volunteer-two');
await applyAndApprove(secondToken, admin.cookie, '0002');
const full = await request(app.getHttpServer() as Server)
.post(`/api/v1/activities/${activity.publicId}/registrations`)
.set('authorization', `Bearer ${secondToken}`)
.send({ clientRequestId: crypto.randomUUID() })
.expect(409);
expect(body<ApiErrorResponse>(full).error.code).toBe('ACTIVITY_FULL');
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activity-registrations/${registration.publicId}/review`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({
clientRequestId: reviewRequestId,
targetStatus: 'APPROVED',
note: '报名信息符合活动要求',
})
.expect(201);
const reviewReplay = await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activity-registrations/${registration.publicId}/review`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({
clientRequestId: reviewRequestId,
targetStatus: 'APPROVED',
note: '报名信息符合活动要求',
})
.expect(201);
expect(
body<ApiSuccessResponse<ActivityRegistrationResponse>>(reviewReplay).data.idempotentReplay,
).toBe(true);
const checkInRequestId = crypto.randomUUID();
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activities/${activity.publicId}/transition`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'REGISTRATION_CLOSED' })
.expect(201);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activities/${activity.publicId}/transition`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'IN_PROGRESS' })
.expect(201);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activity-registrations/${registration.publicId}/check-in`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: checkInRequestId, targetStatus: 'CHECKED_IN' })
.expect(201);
const checkInReplay = await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activity-registrations/${registration.publicId}/check-in`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: checkInRequestId, targetStatus: 'CHECKED_IN' })
.expect(201);
expect(
body<ApiSuccessResponse<ActivityRegistrationResponse>>(checkInReplay).data.idempotentReplay,
).toBe(true);
await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activities/${activity.publicId}/transition`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'COMPLETED' })
.expect(201);
const minutes = await request(app.getHttpServer() as Server)
.post(`/api/v1/admin/activity-registrations/${registration.publicId}/service-minutes`)
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({ clientRequestId: crypto.randomUUID(), serviceMinutes: 180 })
.expect(201);
expect(
body<ApiSuccessResponse<ActivityRegistrationResponse>>(minutes).data.registration
.serviceMinutes,
).toBe(180);
const mine = await request(app.getHttpServer() as Server)
.get('/api/v1/me/volunteer')
.set('authorization', `Bearer ${volunteerToken}`)
.expect(200);
expect(
body<ApiSuccessResponse<VolunteerProfileResponse>>(mine).data.volunteer?.totalServiceMinutes,
).toBe(180);
expect(
await prisma.activityRegistrationHistory.count({
where: {
registrationId: (
await prisma.activityRegistration.findUniqueOrThrow({
where: { publicId: registration.publicId },
})
).id,
},
}),
).toBe(4);
expect(
await prisma.auditLog.count({ where: { resourcePublicId: registration.publicId } }),
).toBeGreaterThanOrEqual(4);
});
it('keeps draft resources private and rejects unapproved volunteers', async () => {
const admin = await createStaff('phase5.privacy.admin', 'ADMIN', '系统管理员');
const manager = await createStaff('phase5.privacy.manager', 'PROJECT_MANAGER', '项目负责人');
const created = await request(app.getHttpServer() as Server)
.post('/api/v1/admin/projects')
.set('origin', ADMIN_ORIGIN)
.set('cookie', admin.cookie)
.send({
clientRequestId: crypto.randomUUID(),
name: '内部筹备项目',
category: 'OTHER',
summary: '该项目尚处于内部筹备阶段,不应出现在公开项目列表中。',
content: '项目内容仍在完善,完成负责人和周期核对后再由有权限的工作人员执行发布。',
managerUserPublicId: manager.user.publicId,
startDate: '2026-09-01',
endDate: '2026-10-31',
isPublic: true,
sortOrder: 0,
})
.expect(201);
const project = body<ApiSuccessResponse<ProjectDetailResponse>>(created).data.project;
await request(app.getHttpServer() as Server)
.get(`/api/v1/public/projects/${project.publicId}`)
.expect(404);
const activity = await prisma.activity.create({
data: {
publicId: crypto.randomUUID(),
activityNo: `HD${crypto.randomUUID().slice(0, 20)}`,
clientRequestId: crypto.randomUUID(),
requestFingerprint: 'a'.repeat(64),
title: '公开报名测试活动',
summary: '用于验证未认证用户不能报名的活动摘要内容。',
content: '用于验证志愿者状态授权边界的完整活动说明内容。',
startAt: new Date('2026-10-18T01:00:00.000Z'),
endAt: new Date('2026-10-18T05:00:00.000Z'),
registrationDeadline: new Date('2026-10-17T12:00:00.000Z'),
locationText: '社区服务中心',
status: 'PUBLISHED',
createdById: admin.user.id,
updatedById: admin.user.id,
},
});
const publicToken = await wechatToken('not-approved');
const denied = await request(app.getHttpServer() as Server)
.post(`/api/v1/activities/${activity.publicId}/registrations`)
.set('authorization', `Bearer ${publicToken}`)
.send({ clientRequestId: crypto.randomUUID() })
.expect(403);
expect(body<ApiErrorResponse>(denied).error.code).toBe('VOLUNTEER_NOT_APPROVED');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { spawn } from 'node:child_process';
function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: environment,
shell: false,
stdio: 'inherit',
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${String(code)}`));
});
});
}
async function main(): Promise<void> {
const testDatabaseUrl = process.env.TEST_DATABASE_URL;
if (!testDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for integration tests');
const parsed = new URL(testDatabaseUrl);
if (parsed.protocol !== 'mysql:' || parsed.pathname !== '/lehuo_test') {
throw new Error('Integration tests are restricted to the lehuo_test MySQL database');
}
const pnpmCli = process.env.npm_execpath;
if (!pnpmCli) throw new Error('npm_execpath is required to locate the active pnpm CLI');
const environment = {
...process.env,
APP_ENV: 'test',
DATABASE_URL: testDatabaseUrl,
};
await run(process.execPath, [pnpmCli, 'exec', 'prisma', 'migrate', 'deploy'], environment);
await run(
process.execPath,
[pnpmCli, 'exec', 'vitest', 'run', '--config', 'vitest.integration.config.ts'],
environment,
);
}
void main();
@@ -0,0 +1,322 @@
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type {
ApiErrorResponse,
ApiSuccessResponse,
CreateServiceApplicationResponse,
PaginatedServiceApplicationsResponse,
RoleCode,
ServiceApplicationDetailResponse,
ServiceCategoriesResponse,
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';
const ADMIN_ORIGIN = 'http://localhost:5173';
let submitIpSequence = 1;
class PhaseTwoWechatAdapter implements WechatAuthAdapter {
exchangeCode(code: string): Promise<{ openid: string; unionid: string }> {
return Promise.resolve({ openid: `phase2-openid-${code}`, unionid: `phase2-unionid-${code}` });
}
}
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 2 service applications', () => {
let app: INestApplication;
let prisma: PrismaService;
let passwords: PasswordService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(WECHAT_AUTH_ADAPTER)
.useValue(new PhaseTwoWechatAdapter())
.compile();
app = moduleRef.createNestApplication<NestExpressApplication>();
(app as NestExpressApplication).set('trust proxy', 1);
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
);
await app.init();
prisma = app.get(PrismaService);
passwords = app.get(PasswordService);
});
beforeEach(async () => {
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
await prisma.contentPostStatusHistory.deleteMany();
await prisma.contentPost.deleteMany();
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 () => {
await app.close();
});
async function wechatToken(code: string): Promise<string> {
const response = await request(app.getHttpServer() as Server)
.post('/api/v1/auth/wechat/login')
.send({ code })
.expect(201);
return body<ApiSuccessResponse<WechatLoginResponse>>(response).data.accessToken;
}
async function createStaff(username: string, roleCode: RoleCode): Promise<string> {
const password = 'correct horse battery staple';
const passwordHash = await passwords.hash(password);
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 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 adminCookie(login);
}
function applicationPayload(clientRequestId = crypto.randomUUID()) {
return {
clientRequestId,
serviceCategory: 'OLD_AGE',
applyMode: 'SELF',
beneficiaryName: '王阿姨',
contactMobile: '13812345678',
regionCode: '丹阳市开发区',
description: '近期行动不便,希望能够得到定期探访和生活协助。',
preferredContactTime: '工作日上午',
needsHomeVisit: true,
mobilityDifficulty: true,
privacyConsent: true,
};
}
async function submit(token: string, payload: Record<string, unknown> = applicationPayload()) {
return request(app.getHttpServer() as Server)
.post('/api/v1/service-applications')
.set('authorization', `Bearer ${token}`)
.set('x-forwarded-for', `198.51.100.${submitIpSequence++}`)
.send(payload);
}
it('publishes the six approved service categories without authentication', async () => {
const response = await request(app.getHttpServer() as Server)
.get('/api/v1/service-categories')
.expect(200);
const result = body<ApiSuccessResponse<ServiceCategoriesResponse>>(response).data;
expect(result.items).toHaveLength(6);
expect(result.items.map((item) => item.code)).toContain('CHILD');
});
it('submits a self application transactionally with encrypted L3 data, consent, history, and audit', async () => {
const token = await wechatToken('self-user');
const response = await submit(token).then((result) => {
expect(result.status).toBe(201);
return result;
});
const result = body<ApiSuccessResponse<CreateServiceApplicationResponse>>(response).data;
expect(result.idempotentReplay).toBe(false);
expect(result.application.applicationNo).toMatch(/^LH\d{8}[0-9A-F]{10}$/);
expect(result.application.status).toBe('SUBMITTED');
expect(result.application.history).toHaveLength(1);
const stored = await prisma.serviceApplication.findFirstOrThrow();
expect(stored.beneficiaryNameEncrypted).not.toContain('王阿姨');
expect(stored.contactMobileEncrypted).not.toContain('13812345678');
expect(stored.descriptionEncrypted).not.toContain('定期探访');
expect(await prisma.consent.count()).toBe(1);
expect(await prisma.serviceApplicationStatusHistory.count()).toBe(1);
expect(
await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_SUBMITTED' } }),
).toBe(1);
});
it('replays the same idempotency key once and rejects changed content', async () => {
const token = await wechatToken('duplicate-user');
const requestId = crypto.randomUUID();
const payload = applicationPayload(requestId);
const first = await submit(token, payload).then((result) => {
expect(result.status).toBe(201);
return result;
});
const second = await submit(token, payload).then((result) => {
expect(result.status).toBe(201);
return result;
});
expect(
body<ApiSuccessResponse<CreateServiceApplicationResponse>>(second).data.idempotentReplay,
).toBe(true);
expect(
body<ApiSuccessResponse<CreateServiceApplicationResponse>>(second).data.application.publicId,
).toBe(
body<ApiSuccessResponse<CreateServiceApplicationResponse>>(first).data.application.publicId,
);
expect(await prisma.serviceApplication.count()).toBe(1);
const conflict = await submit(token, {
...payload,
description: `${payload.description} 内容已修改`,
});
expect(conflict.status).toBe(409);
expect(body<ApiErrorResponse>(conflict).error.code).toBe('DUPLICATE_REQUEST');
});
it('supports a family application and preserves the approved relationship mode', async () => {
const token = await wechatToken('family-user');
const response = await submit(token, {
...applicationPayload(),
applyMode: 'FAMILY',
beneficiaryName: '李奶奶',
}).then((result) => {
expect(result.status).toBe(201);
return result;
});
const application =
body<ApiSuccessResponse<CreateServiceApplicationResponse>>(response).data.application;
expect(application.applyMode).toBe('FAMILY');
expect(application.applyModeLabel).toBe('帮家人申请');
});
it('lists only the current user applications and denies guessed public identifiers', async () => {
const ownerToken = await wechatToken('owner');
const otherToken = await wechatToken('other');
const submitted = await submit(ownerToken).then((response) => {
expect(response.status).toBe(201);
return body<ApiSuccessResponse<CreateServiceApplicationResponse>>(response).data.application;
});
const mine = await request(app.getHttpServer() as Server)
.get('/api/v1/service-applications')
.set('authorization', `Bearer ${ownerToken}`)
.expect(200);
expect(body<ApiSuccessResponse<PaginatedServiceApplicationsResponse>>(mine).data.total).toBe(1);
const others = await request(app.getHttpServer() as Server)
.get('/api/v1/service-applications')
.set('authorization', `Bearer ${otherToken}`)
.expect(200);
expect(body<ApiSuccessResponse<PaginatedServiceApplicationsResponse>>(others).data.total).toBe(
0,
);
await request(app.getHttpServer() as Server)
.get(`/api/v1/service-applications/${submitted.publicId}`)
.set('authorization', `Bearer ${otherToken}`)
.expect(403);
});
it('rejects missing consent, unknown fields, and invalid contact values', async () => {
const token = await wechatToken('validation-user');
const response = await submit(token, {
...applicationPayload(),
contactMobile: '12345',
privacyConsent: false,
internalStatus: 'ACCEPTED',
});
expect(response.status).toBe(400);
expect(body<ApiErrorResponse>(response).error.code).toBe('VALIDATION_ERROR');
expect(await prisma.serviceApplication.count()).toBe(0);
});
it('allows staff to filter masked lists and audits full detail access', async () => {
const token = await wechatToken('admin-visible-owner');
const submitted = await submit(token).then((response) => {
expect(response.status).toBe(201);
return body<ApiSuccessResponse<CreateServiceApplicationResponse>>(response).data.application;
});
const cookie = await createStaff('phase2.staff', 'STAFF');
const list = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/service-applications?serviceCategory=OLD_AGE&status=SUBMITTED')
.set('cookie', cookie)
.expect(200);
const listData = body<ApiSuccessResponse<PaginatedServiceApplicationsResponse>>(list).data;
expect(listData.total).toBe(1);
expect(listData.items[0]?.contactMobileMasked).toBe('138****5678');
expect(JSON.stringify(listData)).not.toContain('13812345678');
const detail = await request(app.getHttpServer() as Server)
.get(`/api/v1/admin/service-applications/${submitted.publicId}`)
.set('cookie', cookie)
.expect(200);
expect(
body<ApiSuccessResponse<ServiceApplicationDetailResponse>>(detail).data.application
.contactMobile,
).toBe('13812345678');
expect(
await prisma.auditLog.count({ where: { action: 'SERVICE_APPLICATION_DETAIL_VIEWED' } }),
).toBe(1);
});
it('denies the admin application list to a role without application read permission', async () => {
const cookie = await createStaff('phase2.volunteer', 'VOLUNTEER');
const response = await request(app.getHttpServer() as Server)
.get('/api/v1/admin/service-applications')
.set('cookie', cookie)
.expect(403);
expect(body<ApiErrorResponse>(response).error.code).toBe('FORBIDDEN');
});
});
+18
View File
@@ -0,0 +1,18 @@
process.env.APP_ENV = 'test';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.API_PORT = '3100';
process.env.SESSION_TOKEN_PEPPER = 'generated-test-session-token-pepper-2026';
process.env.AUDIT_IP_HASH_KEY = 'generated-test-audit-ip-hash-key-2026';
process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 11).toString('base64');
process.env.ADMIN_WEB_ORIGIN = 'http://localhost:5173';
process.env.ADMIN_COOKIE_SECURE = 'false';
process.env.AUTH_SESSION_TTL_SECONDS = '3600';
process.env.WECHAT_SESSION_TTL_SECONDS = '3600';
process.env.WECHAT_APP_ID = 'test-wechat-app-id';
process.env.WECHAT_APP_SECRET = 'generated-test-wechat-secret';
process.env.OBJECT_STORAGE_ENDPOINT = 'http://127.0.0.1:9000';
process.env.OBJECT_STORAGE_REGION = 'auto';
process.env.OBJECT_STORAGE_BUCKET = 'lehuo-test-private';
process.env.OBJECT_STORAGE_ACCESS_KEY = 'generated-test-storage-access';
process.env.OBJECT_STORAGE_SECRET_KEY = 'generated-test-storage-secret';
process.env.OBJECT_STORAGE_FORCE_PATH_STYLE = 'true';