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 = (r: { body: unknown }) => r.body as T; function cookie(r: { headers: Record }) { 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.financialDisclosureStatusHistory.deleteMany(); await prisma.financialDisclosure.deleteMany(); 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>(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>(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>(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: ' 这段内容不得被保存到公开内容中。', }) .expect(400); expect(body(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>(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>(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>(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(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>(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); }); });