feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
@@ -0,0 +1,251 @@
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 = <T>(r: { body: unknown }) => r.body as T;
function cookie(r: { headers: Record<string, unknown> }) {
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.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<ApiSuccessResponse<WechatLoginResponse>>(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<ApiSuccessResponse<ContentPostDetailResponse>>(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<ApiSuccessResponse<PaginatedContentPostsResponse>>(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: '<script>alert(1)</script> 这段内容不得被保存到公开内容中。',
})
.expect(400);
expect(body<ApiErrorResponse>(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<ApiSuccessResponse<ContentPostDetailResponse>>(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<ApiSuccessResponse<PaginatedNotificationsResponse>>(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<ApiSuccessResponse<PaginatedNotificationsResponse>>(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<ApiErrorResponse>(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<ApiSuccessResponse<StatisticsOverview>>(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);
});
});