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