import 'reflect-metadata'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Test } from '@nestjs/testing'; import type { NestExpressApplication } from '@nestjs/platform-express'; import type { AdminWorksheet, AdminWorksheetResource, ContentPublication, } from '@doodle/contracts'; import { AppModule } from '../src/app.module'; import { configureApplication } from '../src/app.setup'; import { AppLogger } from '../src/logging/app-logger'; import { PrismaService } from '../src/database/prisma.service'; import { JwtTokenService } from '../src/auth/jwt-token.service'; import { PublicationService } from '../src/content/publication.service'; import { CategoryService } from '../src/categories/category.service'; import { testConfig } from './test-config'; const databaseUrl = process.env.TEST_DATABASE_URL; test( '真实 PostgreSQL:内容发布可见性、管理员预览、审计、缓存失效及并发', { skip: !databaseUrl }, async (context) => { const url = new URL(databaseUrl!); assert.ok(['localhost', '127.0.0.1'].includes(url.hostname)); assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/); const config = { ...testConfig, databaseUrl: databaseUrl! }; const module = await Test.createTestingModule({ imports: [AppModule.forRoot(config)], }).compile(); const logger = new AppLogger('error', () => {}); const app = module.createNestApplication({ logger, bodyParser: false, }); context.after(() => app.close()); configureApplication(app, config, logger); await app.listen(0, '127.0.0.1'); const origin = await app.getUrl(); const prisma = app.get(PrismaService).client; const tokens = app.get(JwtTokenService); await prisma.user.createMany({ data: [ { id: 'publication-admin', openid: 'publication-admin', role: 'admin', }, { id: 'publication-user', openid: 'publication-user' }, ], }); const admin = await tokens.signAccessToken('publication-admin', 1); const user = await tokens.signAccessToken('publication-user', 1); const actor = { userId: 'publication-admin', authVersion: 1, role: 'admin' as const, }; const call = ( path: string, method = 'GET', body?: unknown, token?: string, ) => fetch(`${origin}/api/v1/${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'publication-test', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000), }); async function data(path: string, token?: string): Promise { const response = await call(path, 'GET', undefined, token); assert.equal( response.status, 200, `${path}: ${await (response.status === 200 ? Promise.resolve('') : response.text())}`, ); return ((await response.json()) as { data: T }).data; } async function patch( path: string, status: string, ): Promise { const response = await call(path, 'PATCH', { status }, admin); assert.equal(response.status, 200); return ((await response.json()) as { data: T }).data; } const category = { name: '发布样本', icon: '📄', color: '#fff' }; await prisma.category.create({ data: { ...category, id: 'pub-root', status: 'active' }, }); await prisma.category.create({ data: { ...category, id: 'pub-child', parentId: 'pub-root', status: 'active', }, }); await prisma.category.create({ data: { ...category, id: 'pub-grandchild', parentId: 'pub-child', status: 'active', }, }); await prisma.category.create({ data: { ...category, id: 'pub-draft-category' }, }); await prisma.asset.create({ data: { id: 'pub-asset', purpose: 'preview', visibility: 'public', sourceRef: 'cloud://never-public/source', }, }); const old = new Date('2024-01-01T00:00:00Z'); for (const [id, status, categoryId] of [ ['pub-live', 'active', 'pub-grandchild'], ['pub-draft', 'draft', 'pub-grandchild'], ['pub-unready', 'draft', 'pub-draft-category'], ['pub-old', 'hidden', 'pub-grandchild'], ['pub-old-hidden', 'hidden', 'pub-grandchild'], ['pub-never-published', 'draft', 'pub-grandchild'], ['pub-rollback', 'draft', 'pub-grandchild'], ]) { await prisma.worksheet.create({ data: { id: id!, title: id!, subtitle: '', categoryId: categoryId!, status: status!, path: '/test', ageMin: 3, ageMax: 6, difficulty: 1, previewAssetId: 'pub-asset', downloads: 1000000n, tags: ['publication'], createdAt: old, }, }); } await prisma.worksheetResource.createMany({ data: [ { id: 'pub-resource', worksheetId: 'pub-live', title: '资源草稿', previewAssetId: 'pub-asset', }, { id: 'pub-resource-unready', worksheetId: 'pub-draft', title: '上级未发布', previewAssetId: 'pub-asset', }, ], }); await context.test( '新分类默认草稿,公开列表/详情隐藏;管理读取仅限当前管理员', async () => { const response = await call( 'admin/categories', 'POST', { ...category, id: 'pub-new' }, admin, ); assert.equal(response.status, 201); assert.equal( ((await response.json()) as { data: { status: string } }) .data.status, 'draft', ); assert.equal((await call('categories/pub-new')).status, 404); assert.equal( ( await prisma.category.findUniqueOrThrow({ where: { id: 'pub-draft-category' }, }) ).status, 'draft', ); const rows = await data>('categories'); assert.ok(!rows.some((row) => row.id === 'pub-new')); for (const path of [ 'admin/categories', 'admin/categories/pub-new', 'admin/worksheets?status=draft', 'admin/worksheets/pub-draft', 'admin/worksheet-resources', 'admin/worksheet-resources/pub-resource', ]) { assert.equal((await call(path)).status, 401); assert.equal( (await call(path, 'GET', undefined, user)).status, 403, ); assert.equal( (await call(path, 'GET', undefined, admin)).status, 200, ); } const preview = await data( 'admin/worksheets/pub-draft', admin, ); assert.equal(preview.status, 'draft'); assert.equal(preview.visible, false); assert.equal(preview.previewImg, null); for (const field of [ 'renderConfig', 'assetKey', 'sourceRef', 'downloadsSeed', ]) assert.ok(!(field in preview)); const drafts = await data<{ items: AdminWorksheet[] }>( 'admin/worksheets?status=draft&tag=publication', admin, ); assert.ok(drafts.items.some((row) => row.id === 'pub-draft')); assert.ok(drafts.items.every((row) => row.status === 'draft')); for (const bad of [null, 'testing', true, 'ACTIVE']) { assert.equal( ( await call( 'admin/categories/pub-new', 'PATCH', { status: bad }, admin, ) ).status, 400, ); assert.equal( ( await call( 'admin/worksheets/pub-draft/status', 'PATCH', { status: bad }, admin, ) ).status, 400, ); } assert.equal( ( await call( 'admin/worksheet-resources?status=testing', 'GET', undefined, admin, ) ).status, 400, ); assert.equal( ( await call( 'admin/worksheet-resources?grade=1', 'GET', undefined, admin, ) ).status, 400, ); }, ); await context.test( '多层祖先下架影响全部/分类/热门/筛选/详情,恢复不改写下属状态', async () => { assert.equal((await call('worksheets/pub-live')).status, 200); await patch('admin/categories/pub-root', 'hidden'); for (const id of ['pub-root', 'pub-child', 'pub-grandchild']) assert.equal((await call(`categories/${id}`)).status, 404); for (const query of [ 'tag=publication', 'categoryId=pub-grandchild', 'tag=publication&sort=hot', 'tag=publication&sort=latest', 'tag=publication&age=4', 'tag=publication&ageBand=3-4', ]) { const result = await data<{ total: number; items: unknown[]; }>(`worksheets?${query}`); assert.equal(result.total, 0); assert.deepEqual(result.items, []); } assert.equal((await call('worksheets/pub-live')).status, 404); assert.equal( ( await data( 'admin/worksheets/pub-live', admin, ) ).visible, false, ); assert.equal( ( await prisma.worksheet.findUniqueOrThrow({ where: { id: 'pub-live' }, }) ).status, 'active', ); await patch('admin/categories/pub-root', 'active'); assert.equal((await call('worksheets/pub-live')).status, 200); assert.equal((await call('worksheets/pub-draft')).status, 404); assert.equal( (await call('worksheets?status=draft')).status, 400, ); assert.equal( (await call('worksheets?preview=true')).status, 400, ); assert.equal( (await call('categories/pub-draft-category?preview=true')) .status, 404, ); }, ); await context.test( '状态修改鉴权、上级发布前置条件与资源有效可见性', async () => { for (const path of [ 'admin/worksheets/pub-draft/status', 'admin/worksheet-resources/pub-resource/status', ]) { assert.equal( (await call(path, 'PATCH', { status: 'active' })) .status, 401, ); assert.equal( (await call(path, 'PATCH', { status: 'active' }, user)) .status, 403, ); } assert.equal( ( await call( 'admin/worksheets/pub-unready/status', 'PATCH', { status: 'active' }, admin, ) ).status, 409, ); assert.equal( ( await call( 'admin/worksheet-resources/pub-resource-unready/status', 'PATCH', { status: 'active' }, admin, ) ).status, 409, ); const published = await patch( 'admin/worksheet-resources/pub-resource/status', 'active', ); assert.ok(published.firstPublishedAt); assert.equal( ( await data( 'admin/worksheet-resources/pub-resource', admin, ) ).visible, true, ); await patch('admin/worksheets/pub-live/status', 'hidden'); assert.equal( ( await data( 'admin/worksheet-resources/pub-resource', admin, ) ).visible, false, ); assert.equal( ( await prisma.worksheetResource.findUniqueOrThrow({ where: { id: 'pub-resource' }, }) ).status, 'active', ); await patch('admin/worksheets/pub-live/status', 'active'); assert.equal( ( await data( 'admin/worksheet-resources/pub-resource', admin, ) ).visible, true, ); const resources = await data<{ total: number; items: AdminWorksheetResource[]; }>( 'admin/worksheet-resources?worksheetId=pub-live&status=active&pageSize=1', admin, ); assert.equal(resources.total, 1); assert.equal(resources.items[0]?.id, 'pub-resource'); }, ); await context.test( '首次发布登记、重发保持时间、未知旧首发不伪造,并发重复仅一份审计', async () => { const paths = [ 'admin/worksheets/pub-draft/status', 'admin/worksheet-resources/pub-resource/status', ]; const first = await patch(paths[0]!, 'active'); assert.ok(first.firstPublishedAt); for (const path of paths) { const before = await data( path.replace('/status', ''), admin, ); await patch(path, 'hidden'); const after = await patch(path, 'active'); assert.equal( after.firstPublishedAt, before.firstPublishedAt, ); } assert.equal( (await patch('admin/worksheets/pub-old/status', 'active')) .firstPublishedAt, null, ); await patch('admin/worksheets/pub-old-hidden/status', 'draft'); assert.equal( ( await patch( 'admin/worksheets/pub-old-hidden/status', 'active', ) ).firstPublishedAt, null, ); await patch('admin/worksheets/pub-old/status', 'draft'); assert.equal( (await patch('admin/worksheets/pub-old/status', 'active')) .firstPublishedAt, null, ); await patch( 'admin/worksheets/pub-never-published/status', 'hidden', ); await patch( 'admin/worksheets/pub-never-published/status', 'draft', ); assert.ok( ( await patch( 'admin/worksheets/pub-never-published/status', 'active', ) ).firstPublishedAt, ); const audits = await prisma.adminAuditLog.count({ where: { resourceId: 'pub-draft', action: 'worksheet.status', }, }); const results = await Promise.all([ patch(paths[0]!, 'hidden'), patch(paths[0]!, 'hidden'), ]); assert.deepEqual(results[0], results[1]); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: 'pub-draft', action: 'worksheet.status', }, }), audits + 1, ); const stored = await prisma.worksheet.findUniqueOrThrow({ where: { id: 'pub-draft' }, }); await patch(paths[0]!, 'hidden'); assert.equal( ( await prisma.worksheet.findUniqueOrThrow({ where: { id: 'pub-draft' }, }) ).updatedAt.toISOString(), stored.updatedAt.toISOString(), ); }, ); await context.test( '状态变更同事务撤销页面快照;无变更不增加版本', async () => { await prisma.pageConfig.create({ data: { id: 'publication-snapshot', home: { featured: [{ id: 'pub-live' }] }, category: { test: true }, age: { test: true }, version: 9n, publishedVersion: 9n, publishedAt: new Date(), }, }); await patch('admin/categories/pub-root', 'hidden'); const snapshot = await prisma.pageConfig.findUniqueOrThrow({ where: { id: 'publication-snapshot' }, }); assert.equal(snapshot.version, 10n); for (const key of [ 'home', 'category', 'age', 'publishedVersion', 'publishedAt', ] as const) assert.equal(snapshot[key], null); await patch('admin/categories/pub-root', 'hidden'); assert.deepEqual( await prisma.pageConfig.findUniqueOrThrow({ where: { id: 'publication-snapshot' }, }), snapshot, ); await patch('admin/categories/pub-root', 'active'); }, ); await context.test( '旧分类导出省略 status 时重导保留下架,新导入分类默认草稿', async () => { const service = app.get(CategoryService); await patch('admin/categories/pub-child', 'hidden'); const before = await prisma.category.findUniqueOrThrow({ where: { id: 'pub-child' }, }); const report = await service.importExport( JSON.stringify([ { ...category, id: 'pub-child', parentId: 'pub-root', sortOrder: 0, }, ]), false, 'publication-import', 'test', ); assert.equal(report.unchanged, 1); assert.deepEqual( await prisma.category.findUniqueOrThrow({ where: { id: 'pub-child' }, }), before, ); await service.importExport( JSON.stringify([{ ...category, id: 'pub-import-draft' }]), false, 'publication-import', 'test', ); assert.equal( ( await prisma.category.findUniqueOrThrow({ where: { id: 'pub-import-draft' }, }) ).status, 'draft', ); await patch('admin/categories/pub-child', 'active'); }, ); await context.test( '审计失败回滚状态、首发时间与快照;服务层复核禁用、降权和版本失效', async () => { const snapshots = await prisma.pageConfig.findMany(); await prisma.$executeRawUnsafe( `CREATE FUNCTION fail_publication_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.resource_id = 'pub-rollback' THEN RAISE EXCEPTION 'test rollback'; END IF; RETURN NEW; END $$`, ); await prisma.$executeRawUnsafe( 'CREATE TRIGGER fail_publication_audit BEFORE INSERT ON public.admin_audit_logs FOR EACH ROW EXECUTE FUNCTION fail_publication_audit()', ); try { assert.equal( ( await call( 'admin/worksheets/pub-rollback/status', 'PATCH', { status: 'active' }, admin, ) ).status, 503, ); const row = await prisma.worksheet.findUniqueOrThrow({ where: { id: 'pub-rollback' }, }); assert.equal(row.status, 'draft'); assert.equal(row.firstPublishedAt, null); assert.deepEqual( await prisma.pageConfig.findMany(), snapshots, ); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: 'pub-rollback' }, }), 0, ); } finally { await prisma.$executeRawUnsafe( 'DROP TRIGGER fail_publication_audit ON public.admin_audit_logs', ); await prisma.$executeRawUnsafe( 'DROP FUNCTION fail_publication_audit()', ); } const service = app.get(PublicationService); await prisma.user.update({ where: { id: actor.userId }, data: { role: 'user' }, }); await assert.rejects( service.update( 'worksheet', 'pub-rollback', { status: 'active' }, actor, 'stale', ), { status: 403 }, ); assert.equal( ( await call( 'admin/worksheets/pub-live', 'GET', undefined, admin, ) ).status, 403, ); await prisma.user.update({ where: { id: actor.userId }, data: { role: 'admin', status: 'disabled' }, }); await assert.rejects( service.update( 'worksheet', 'pub-rollback', { status: 'active' }, actor, 'stale', ), { status: 401 }, ); await prisma.user.update({ where: { id: actor.userId }, data: { status: 'active', authVersion: 2 }, }); assert.equal( ( await call( 'admin/worksheet-resources', 'GET', undefined, admin, ) ).status, 401, ); await assert.rejects( service.update( 'resource', 'pub-resource', { status: 'hidden' }, actor, 'stale', ), { status: 401 }, ); }, ); }, );