415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
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 () => {
|
|
await prisma.financialDisclosureStatusHistory.deleteMany();
|
|
await prisma.financialDisclosure.deleteMany();
|
|
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');
|
|
});
|
|
});
|