323 lines
13 KiB
TypeScript
323 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.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');
|
||
|
|
});
|
||
|
|
});
|