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 { ApiSuccess, Category } 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 { CategoryService } from '../src/categories/category.service'; import { testConfig } from './test-config'; const databaseUrl = process.env.TEST_DATABASE_URL; const item = { status: 'active' as const, id: 'math', name: '数感启蒙', icon: '📐', color: '#CDB4DB', sortOrder: 0, parentId: null, }; test( '真实 PostgreSQL:分类接口、并发树校验、审计及幂等导入', { skip: !databaseUrl }, async (context) => { const parsed = new URL(databaseUrl!); assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname)); assert.match(parsed.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 service = app.get(CategoryService); const tokens = app.get(JwtTokenService); await prisma.user.createMany({ data: [ { id: 'category-admin', openid: 'category-admin', role: 'admin', }, { id: 'category-admin-two', openid: 'category-admin-two', role: 'admin', }, { id: 'category-user', openid: 'category-user' }, ], }); const admin = await tokens.signAccessToken('category-admin', 1); const secondAdmin = await tokens.signAccessToken( 'category-admin-two', 1, ); const user = await tokens.signAccessToken('category-user', 1); const call = ( path: string, method = 'GET', body?: unknown, token?: string, ) => fetch(`${origin}/api/v1/${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'category-test-request', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(15_000), }); const data = async (response: Response, status = 200) => { assert.equal(response.status, status); const body = (await response.json()) as ApiSuccess; assert.equal(body.code, 0); return body.data; }; await context.test( '公开列表无需登录,写入限定管理员,校验失败不写库', async () => { const initial = await call('categories'); assert.equal(initial.status, 200); assert.deepEqual( ((await initial.json()) as ApiSuccess).data, [], ); for (const [path, method, body] of [ ['admin/categories', 'POST', item], ['admin/categories/math', 'PATCH', { name: '无权限修改' }], ['admin/categories/math', 'DELETE', undefined], ] as const) { assert.equal((await call(path, method, body)).status, 401); assert.equal( (await call(path, method, body, user)).status, 403, ); } for (const input of [ { ...item, name: null }, { ...item, sortOrder: '0' }, { ...item, unexpected: true }, { ...item, parentId: 'missing' }, ]) assert.equal( (await call('admin/categories', 'POST', input, admin)) .status, 400, ); assert.equal(await prisma.category.count(), 0); assert.equal( await prisma.adminAuditLog.count({ where: { resourceType: 'categories' }, }), 0, ); }, ); await context.test( '创建、公开详情、稳定排序、局部更新及同事务审计', async () => { const created = await data( await call('admin/categories', 'POST', item, admin), 201, ); assert.equal(created.id, 'math'); assert.equal(created.icon, '📐'); assert.equal(created.parentId, null); assert.deepEqual( Object.keys(created).sort(), [ 'status', 'id', 'name', 'icon', 'color', 'sortOrder', 'parentId', 'createdAt', 'updatedAt', ].sort(), ); assert.equal( (await call('admin/categories', 'POST', item, admin)) .status, 409, ); assert.equal((await call('categories/missing')).status, 404); assert.equal( (await data(await call('categories/math'))).name, item.name, ); await data( await call( 'admin/categories', 'POST', { ...item, id: 'Math' }, admin, ), 201, ); await data( await call( 'admin/categories', 'POST', { ...item, id: 'child', parentId: 'math', sortOrder: 2, }, admin, ), 201, ); const list = await call('categories'); assert.deepEqual( ((await list.json()) as ApiSuccess).data.map( (row) => row.id, ), ['Math', 'math', 'child'], ); const updated = await data( await call( 'admin/categories/math', 'PATCH', { name: '数学启蒙' }, admin, ), ); assert.equal(updated.name, '数学启蒙'); assert.equal(updated.icon, item.icon); assert.equal(updated.createdAt, created.createdAt); const audit = await prisma.adminAuditLog.findFirstOrThrow({ where: { resourceType: 'categories', resourceId: 'math', action: 'category.update', }, }); assert.equal(audit.actorUserId, 'category-admin'); assert.equal(audit.requestId, 'category-test-request'); assert.equal( (audit.beforeData as { name: string }).name, item.name, ); assert.equal( (audit.afterData as { name: string }).name, updated.name, ); const count = await prisma.adminAuditLog.count(); const unchanged = await data( await call( 'admin/categories/math', 'PATCH', { name: updated.name }, admin, ), ); assert.equal(unchanged.updatedAt, updated.updatedAt); assert.equal(await prisma.adminAuditLog.count(), count); for (const patch of [ {}, { id: 'new-id' }, { icon: null }, { parentId: 'child' }, { parentId: 'math' }, ]) assert.equal( ( await call( 'admin/categories/math', 'PATCH', patch, admin, ) ).status, 400, ); await data( await call( 'admin/categories/child', 'PATCH', { parentId: '' }, admin, ), ); assert.equal( (await data(await call('categories/child'))).parentId, null, ); }, ); await context.test( '不同管理员并发互设父级只能成功一个,事务内再次检查账户', async () => { const results = await Promise.all([ call( 'admin/categories/math', 'PATCH', { parentId: 'Math' }, admin, ), call( 'admin/categories/Math', 'PATCH', { parentId: 'math' }, secondAdmin, ), ]); assert.deepEqual( results.map((r) => r.status).sort(), [200, 400], ); await prisma.user.update({ where: { id: 'category-admin' }, data: { role: 'user' }, }); await assert.rejects( service.create( { ...item, id: 'forged' }, { userId: 'category-admin', authVersion: 1, role: 'admin', }, 'recheck', ), ); const staleActor = { userId: 'category-admin', authVersion: 1, role: 'admin' as const, }; await assert.rejects( service.update( 'math', { name: '无权限修改' }, staleActor, 'recheck', ), { status: 403 }, ); await assert.rejects( service.remove('math', staleActor, 'recheck'), { status: 403 }, ); assert.equal( await prisma.category.count({ where: { id: 'forged' } }), 0, ); await prisma.user.update({ where: { id: 'category-admin' }, data: { role: 'admin' }, }); }, ); await context.test( '管理员删除无引用分类,审计保留删除前快照;重复删除返回 404', async () => { const id = 'delete-leaf'; const before = await data( await call( 'admin/categories', 'POST', { ...item, id }, admin, ), 201, ); const response = await call( `admin/categories/${id}`, 'DELETE', undefined, admin, ); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { code: 0, message: 'ok', data: { id }, }); assert.equal((await call(`categories/${id}`)).status, 404); const audit = await prisma.adminAuditLog.findFirstOrThrow({ where: { resourceId: id, action: 'category.delete' }, }); assert.equal(audit.actorUserId, 'category-admin'); assert.equal(audit.actorType, 'admin'); assert.equal(audit.requestId, 'category-test-request'); assert.equal( (audit.beforeData as { name: string }).name, before.name, ); assert.equal(audit.afterData, null); assert.equal( ( await call( `admin/categories/${id}`, 'DELETE', undefined, admin, ) ).status, 404, ); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: id, action: 'category.delete' }, }), 1, ); }, ); await context.test( '有子分类或任何状态题型引用时返回 409,不删除关联内容或记录成功审计', async () => { await prisma.category.createMany({ data: [ { ...item, id: 'delete-parent' }, { ...item, id: 'delete-used' }, ], }); await prisma.category.create({ data: { ...item, id: 'delete-child', parentId: 'delete-parent', }, }); await prisma.asset.create({ data: { id: 'delete-test-preview', purpose: 'preview', visibility: 'public', }, }); await prisma.worksheet.create({ data: { id: 'delete-test-worksheet', title: '删除保护测试', subtitle: '', categoryId: 'delete-used', path: '/test', ageMin: 3, ageMax: 6, difficulty: 1, previewAssetId: 'delete-test-preview', rendererKey: 'test', }, }); assert.equal( ( await call( 'admin/categories/delete-parent', 'DELETE', undefined, admin, ) ).status, 409, ); for (const status of ['draft', 'active', 'hidden']) { await prisma.worksheet.update({ where: { id: 'delete-test-worksheet' }, data: { status }, }); assert.equal( ( await call( 'admin/categories/delete-used', 'DELETE', undefined, admin, ) ).status, 409, ); } assert.equal( await prisma.category.count({ where: { id: { in: [ 'delete-parent', 'delete-child', 'delete-used', ], }, }, }), 3, ); assert.equal( await prisma.worksheet.count({ where: { id: 'delete-test-worksheet' }, }), 1, ); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: { in: ['delete-parent', 'delete-used'], }, action: 'category.delete', }, }), 0, ); }, ); await context.test( '审计失败会回滚删除,并发删除只成功一次', async () => { await prisma.category.createMany({ data: [ { ...item, id: 'rollback-delete' }, { ...item, id: 'concurrent-delete' }, ], }); // 在隔离测试库制造审计失败,验证删行与审计确实处在同一事务。 await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs ADD CONSTRAINT category_delete_audit_test CHECK (action <> 'category.delete' OR resource_id <> 'rollback-delete')`; try { assert.equal( ( await call( 'admin/categories/rollback-delete', 'DELETE', undefined, admin, ) ).status, 503, ); assert.equal( await prisma.category.count({ where: { id: 'rollback-delete' }, }), 1, ); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: 'rollback-delete', action: 'category.delete', }, }), 0, ); } finally { await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs DROP CONSTRAINT category_delete_audit_test`; } const responses = await Promise.all([ call( 'admin/categories/concurrent-delete', 'DELETE', undefined, admin, ), call( 'admin/categories/concurrent-delete', 'DELETE', undefined, secondAdmin, ), ]); assert.deepEqual( responses.map((response) => response.status).sort(), [200, 404], ); assert.equal( await prisma.adminAuditLog.count({ where: { resourceId: 'concurrent-delete', action: 'category.delete', }, }), 1, ); }, ); await context.test( '禁用、降权、凭证撤销后,新增/修改/删除都立即失去权限', async () => { const actions = [ [ 'admin/categories', 'POST', { ...item, id: 'forbidden-create' }, ], ['admin/categories/math', 'PATCH', { name: '无权限修改' }], ['admin/categories/math', 'DELETE', undefined], ] as const; const check = async (status: number) => { for (const [path, method, body] of actions) assert.equal( (await call(path, method, body, secondAdmin)) .status, status, ); }; await prisma.user.update({ where: { id: 'category-admin-two' }, data: { status: 'disabled' }, }); await check(401); await prisma.user.update({ where: { id: 'category-admin-two' }, data: { status: 'active', role: 'user' }, }); await check(403); await prisma.user.update({ where: { id: 'category-admin-two' }, data: { role: 'admin', authVersion: { increment: 1 } }, }); await check(401); assert.equal( await prisma.category.count({ where: { id: 'forbidden-create' }, }), 0, ); assert.ok( await prisma.category.findUnique({ where: { id: 'math' } }), ); }, ); await context.test( '子先父后导入、预演不写库、缺时报告、重导不刷新时间/审计', async () => { const rows = [ { ...item, _id: 'import-child', id: 'import-child', parentId: 'import-root', }, { ...item, id: 'import-root' }, ]; const source = rows .map((row) => JSON.stringify(row)) .join('\n'); const count = await prisma.category.count(); const audits = await prisma.adminAuditLog.count(); const preview = await service.importExport( source, true, 'dry-run', 'test', ); assert.equal(preview.inserted, 2); assert.equal(preview.missingCreatedAt, 2); assert.equal(await prisma.category.count(), count); assert.equal(await prisma.adminAuditLog.count(), audits); const applied = await service.importExport( source, false, 'import-test', 'sha256=test', ); assert.equal(applied.inserted, 2); const before = await prisma.category.findUniqueOrThrow({ where: { id: 'import-root' }, }); assert.equal(before.createdAt.toISOString(), applied.batchAt); const repeated = await service.importExport( source, false, 'repeat', 'test', ); assert.equal(repeated.unchanged, 2); assert.equal(repeated.inserted + repeated.updated, 0); assert.deepEqual( await prisma.category.findUniqueOrThrow({ where: { id: 'import-root' }, }), before, ); assert.equal(await prisma.adminAuditLog.count(), audits + 2); const updated = await service.importExport( JSON.stringify([ { ...item, id: 'import-root', name: '新名称' }, ]), false, 'import-update', 'test', ); assert.equal(updated.updated, 1); assert.equal( ( await prisma.category.findUniqueOrThrow({ where: { id: 'import-root' }, }) ).createdAt.toISOString(), before.createdAt.toISOString(), ); }, ); await context.test( '导入后半批失败会回滚已写行及审计,且校验现有树中的循环', async () => { const count = await prisma.category.count(); const audits = await prisma.adminAuditLog.count(); await assert.rejects( service.importExport( JSON.stringify([ { ...item, id: 'rollback-first' }, { ...item, id: 'rollback-second', createdAt: '2099-01-01T00:00:00Z', }, ]), false, 'rollback', 'test', ), ); assert.equal(await prisma.category.count(), count); assert.equal(await prisma.adminAuditLog.count(), audits); await assert.rejects( service.importExport( JSON.stringify([ { ...item, id: 'import-root', parentId: 'import-child', }, ]), false, 'cycle', 'test', ), ); await assert.rejects( service.importExport( JSON.stringify([ { ...item, id: 'orphan', parentId: 'not-found' }, ]), false, 'orphan', 'test', ), ); }, ); }, );