Files
lehuo/apps/api/test/service-applications.integration-spec.ts

325 lines
13 KiB
TypeScript

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.financialDisclosureStatusHistory.deleteMany();
await prisma.financialDisclosure.deleteMany();
await prisma.notificationDelivery.deleteMany();
await prisma.notification.deleteMany();
await prisma.notificationPreference.deleteMany();
await prisma.contentPostStatusHistory.deleteMany();
await prisma.contentPost.deleteMany();
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');
});
});