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(response: { body: unknown }): T { return response.body as T; } function adminCookie(response: { headers: Record }): 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(); 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(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 { const response = await request(app.getHttpServer() as Server) .post('/api/v1/auth/wechat/login') .send({ code }) .expect(201); return body>(response).data.accessToken; } async function applyAndApprove( token: string, adminCookieValue: string, suffix: string, ): Promise { 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>(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>(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>(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>(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>(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>(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(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>(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>(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>(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>(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>(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(denied).error.code).toBe('VOLUNTEER_NOT_APPROVED'); }); });