feat: complete V1 phases 0-7
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user