import 'reflect-metadata'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { test } from 'node:test'; import { Test } from '@nestjs/testing'; import type { NestExpressApplication } from '@nestjs/platform-express'; 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 { ASSET_STORAGE } from '../src/assets/qiniu.gateway'; import { AssetService } from '../src/assets/asset.service'; import { seedPaperSheets } from '../src/worksheets/paper-sheet-seed'; import { PAPER_SHEET_PATH, PAPER_SHEET_PRESETS, } from '../src/worksheets/paper-sheet-presets'; import { SiteService } from '../src/site/site.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.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/); assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname)); let holdUpload: Promise | undefined; let uploadStarted: (() => void) | undefined; const writes: string[] = []; const removals: string[] = []; const module = await Test.createTestingModule({ imports: [ AppModule.forRoot({ ...testConfig, databaseUrl: databaseUrl!, qiniuBucket: 'test-public', qiniuPublicDomain: 'https://cdn.example.test', }), ], }) .overrideProvider(ASSET_STORAGE) .useValue({ async put(key: string) { writes.push(key); uploadStarted?.(); if (holdUpload) await holdUpload; }, async remove(_bucket: string, key: string) { removals.push(key); }, }) .compile(); const logger = new AppLogger('error', () => {}); const app = module.createNestApplication({ bodyParser: false, logger, }); context.after(() => app.close()); configureApplication( app, { ...testConfig, databaseUrl: databaseUrl! }, logger, ); await app.listen(0, '127.0.0.1'); const origin = await app.getUrl(); const db = app.get(PrismaService).client; const tokenService = app.get(JwtTokenService); const ids = { admin: `m-admin-${randomUUID()}`, a: `m-a-${randomUUID()}`, b: `m-b-${randomUUID()}`, }; const tokens: Record = {}; for (const [name, id] of Object.entries(ids)) { await db.user.create({ data: { id, openid: id, role: name === 'admin' ? 'admin' : 'user', }, }); tokens[name] = ( await tokenService.issueAccessToken(id, 1) ).accessToken; } const categoryId = `m-cat-${randomUUID()}`; await db.category.create({ data: { id: categoryId, name: '测试分类', icon: '📘', color: '#fff', status: 'active', }, }); const previewId = `m-preview-${randomUUID()}`; await db.asset.create({ data: { id: previewId, provider: 'external', externalUrl: 'https://cdn.example.test/a.jpg', purpose: 'preview', visibility: 'public', status: 'ready', mimeType: 'image/jpeg', }, }); const worksheetId = `m-worksheet-${randomUUID()}`; await db.worksheet.create({ data: { id: worksheetId, title: '测试题型', subtitle: '', categoryId, path: '/mathPages/mathDraw/mathDraw', ageMin: 3, ageMax: 8, difficulty: 1, previewAssetId: previewId, status: 'active', likesSeed: 100, downloadsSeed: 100, }, }); // JSON 类型仅用于测试响应解析,生产路径始终使用 DTO 与显式白名单。 async function request( path: string, token = '', method = 'GET', data?: unknown, ) { const response = await fetch(`${origin}/api/v1${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(data === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(data === undefined ? {} : { body: JSON.stringify(data) }), }); return { status: response.status, body: (await response.json()) as { code: number; data: Record; reason?: string; }, }; } await context.test( '本人接口拒绝匿名、越权字段;管理员接口拒绝普通用户', async () => { assert.equal((await request('/me/favorites')).status, 401); assert.equal( ( await request('/me/profile', tokens.a, 'PUT', { role: 'admin', }) ).status, 400, ); assert.equal( (await request('/me/favorites?userId=other', tokens.a)) .status, 400, ); assert.equal( (await request('/admin/site/config', tokens.a)).status, 403, ); assert.equal( ( await request( '/admin/worksheets/batch', tokens.a, 'POST', { patches: [{ id: worksheetId, likes: 100 }] }, ) ).status, 403, ); }, ); await context.test( '并发收藏幂等,真实计数只变一次;列表与状态仅返回本人数据', async () => { const replies = await Promise.all( Array.from({ length: 8 }, () => request( `/me/favorites/${worksheetId}`, tokens.a, 'PUT', ), ), ); assert.ok(replies.every((reply) => reply.status === 200)); assert.equal( await db.favorite.count({ where: { userId: ids.a, worksheetId }, }), 1, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).likes, 1n, ); const own = await request('/me/favorites', tokens.a); assert.equal((own.body.data.items as Array).length, 1); assert.equal( (await request('/me/favorites', tokens.b)).body.data.total, 0, ); assert.deepEqual( ( await request('/me/favorites/check', tokens.b, 'POST', { worksheetIds: [worksheetId], }) ).body.data, {}, ); assert.equal( ( await request('/me/favorites/check', tokens.a, 'POST', { worksheetIds: [worksheetId], }) ).body.data[worksheetId], true, ); }, ); const exportId = randomUUID(); await context.test( '并发成功导出只计一次下载,冲突与未发布题型拒绝写入', async () => { const replies = await Promise.all( Array.from({ length: 8 }, () => request('/me/downloads', tokens.a, 'POST', { worksheetId, exportId, }), ), ); assert.ok(replies.every((reply) => reply.status === 201)); assert.equal( await db.downloadLog.count({ where: { userId: ids.a } }), 1, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).downloads, 1n, ); assert.equal( (await db.user.findUniqueOrThrow({ where: { id: ids.a } })) .totalDownloads, 1n, ); assert.equal( ( await request('/me/downloads', tokens.a, 'POST', { worksheetId: 'other', exportId, }) ).status, 409, ); assert.equal( ( await request('/me/downloads', tokens.a, 'POST', { worksheetId: 'missing', exportId: randomUUID(), }) ).status, 404, ); assert.equal( (await request('/me/stats', tokens.a)).body.data .downloadCount, 1, ); }, ); const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+kWQAAAABJRU5ErkJggg==', 'base64', ); async function upload( path: string, token: string, id = randomUUID(), bytes = png, ) { const data = new FormData(); data.append('uploadId', id); data.append( 'file', new Blob([new Uint8Array(bytes)], { type: 'image/png' }), 'a.png', ); const response = await fetch(`${origin}/api/v1${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: data, }); return { status: response.status, body: (await response.json()) as { data: { id: string; url: string }; }, }; } let avatarId = ''; await context.test( '上传头像后仅可绑定本人资源,GET /me 返回新域名;清除资料安排对象删除', async () => { const uploadId = randomUUID(); const avatar = await upload( '/me/avatar-upload', tokens.a!, uploadId, ); assert.equal(avatar.status, 201); avatarId = avatar.body.data.id; assert.match( avatar.body.data.url, /^https:\/\/cdn.example.test\/doodle-assets\/user-avatars\//, ); assert.equal( (await upload('/me/avatar-upload', tokens.a!, uploadId)) .body.data.id, avatarId, ); assert.equal( ( await request('/me/profile', tokens.b, 'PUT', { avatarAssetId: avatarId, }) ).status, 400, ); const profile = await request('/me/profile', tokens.a, 'PUT', { nickName: '新昵称', avatarAssetId: avatarId, }); assert.equal(profile.status, 200); assert.equal(profile.body.data.avatarUrl, avatar.body.data.url); assert.equal(profile.body.data.nickName, '新昵称'); assert.equal( (await request('/me', tokens.a)).body.data.avatarAssetId, avatarId, ); assert.equal( (await upload('/admin/assets/preview-upload', tokens.a!)) .status, 403, ); assert.equal( ( await upload( '/me/avatar-upload', tokens.a!, randomUUID(), Buffer.from('not an image'), ) ).status, 400, ); assert.equal( (await request('/me/profile', tokens.a, 'DELETE')).status, 200, ); assert.equal( ( await db.asset.findUniqueOrThrow({ where: { id: avatarId }, }) ).status, 'deleting', ); await app.get(AssetService).cleanup(); assert.equal( ( await db.asset.findUniqueOrThrow({ where: { id: avatarId }, }) ).status, 'deleted', ); assert.equal(removals.length, 1); }, ); await context.test( '管理员发布、PUT 分类更新和批次预演;失败批次完整回滚', async () => { assert.equal( ( await request( `/admin/categories/${categoryId}`, tokens.admin, 'PUT', { name: '新分类名' }, ) ).status, 200, ); const preview = await upload( '/admin/assets/preview-upload', tokens.admin!, ); assert.equal(preview.status, 201); const id = `m-publish-${randomUUID()}`; const input = { id, title: '新题型', subtitle: '', categoryId, path: '/mathPages/mathDraw/mathDraw', ageMin: 3, ageMax: 8, difficulty: 1, previewAssetId: preview.body.data.id, tags: ['测试'], status: 'active', grades: [1, 3], }; assert.equal( ( await request( '/admin/worksheets/publish', tokens.admin, 'POST', input, ) ).status, 201, ); assert.match( String( (await request(`/worksheets/${id}`)).body.data .previewImg, ), /^https:\/\/cdn.example.test\//, ); await request( '/admin/worksheets/publish', tokens.admin, 'POST', { ...input, id: worksheetId }, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).firstPublishedAt, null, ); await request( '/admin/worksheets/publish', tokens.admin, 'POST', { ...input, id: worksheetId, status: 'draft' }, ); await request( '/admin/worksheets/publish', tokens.admin, 'POST', { ...input, id: worksheetId, status: 'active' }, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).firstPublishedAt, null, ); const before = ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).likes; assert.equal( ( await request( '/admin/worksheets/batch', tokens.admin, 'POST', { patches: [{ id: worksheetId, likes: 77 }], dryRun: true, }, ) ).status, 201, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).likes, before, ); assert.equal( ( await request( '/admin/worksheets/batch', tokens.admin, 'POST', { patches: [ { id: worksheetId, likes: 77 }, { id: 'zzz-missing', likes: 1 }, ], }, ) ).status, 404, ); assert.equal( ( await db.worksheet.findUniqueOrThrow({ where: { id: worksheetId }, }) ).likes, before, ); assert.equal( ( await request( '/admin/worksheets/batch', tokens.admin, 'POST', { patches: [ { id: worksheetId, tags: ['新的标签'], likesSeed: '123', grades: [1, 5], }, ], }, ) ).status, 201, ); }, ); await context.test( '分龄覆盖、首页重建和持久开关;旧预拉取地址不受影响', async () => { const age = await request('/recommendations/age?ageBand=5-6'); assert.equal(age.status, 200); assert.ok( (age.body.data.items as Array<{ id: string }>).some( (row) => row.id === worksheetId, ), ); const rebuilt = await request( '/admin/site/rebuild', tokens.admin, 'POST', { page: 'home', featuredIds: [worksheetId], hotIds: [], sections: [], }, ); assert.equal(rebuilt.status, 201); const config = await request('/page-config'); assert.equal( ( config.body.data.home as { featured: Array<{ id: string }>; } ).featured[0]?.id, worksheetId, ); assert.equal( ( await request( '/admin/site/timer', tokens.admin, 'PUT', { enabled: false }, ) ).status, 200, ); assert.equal( (await request('/admin/site/timer', tokens.admin)).body.data .enabled, false, ); assert.ok( writes.every((key) => !key.includes('page-config.json')), ); assert.equal( ( await request( `/admin/worksheets/${worksheetId}/status`, tokens.admin, 'PUT', { status: 'hidden' }, ) ).status, 200, ); const updated = await request('/page-config'); assert.equal( (updated.body.data.home as { featured: unknown[] }).featured .length, 0, ); const records = await request('/me/favorites', tokens.a); assert.equal( ( records.body.data.items as Array<{ worksheet: unknown }> )[0]?.worksheet, null, ); }, ); await context.test( '定时任务通过持久 jobRun 去重,不重复执行已成功日期', async () => { await request('/admin/site/timer', tokens.admin, 'PUT', { enabled: true, }); const site = app.get(SiteService); const date = new Date('2031-01-01T22:00:00Z'); await Promise.all([site.tick(date), site.tick(date)]); await site.tick(date); const job = await db.jobRun.findUniqueOrThrow({ where: { jobName_runKey: { jobName: 'home-auto-refresh', runKey: '2031-01-01', }, }, }); assert.equal(job.status, 'succeeded'); assert.equal(job.attempt, 1); }, ); await context.test( '注销与慢上传并发时,迟到文件仍会重新进入删除队列', async () => { const id = `m-race-${randomUUID()}`; await db.user.create({ data: { id, openid: id } }); const token = (await tokenService.issueAccessToken(id, 1)) .accessToken; let release!: () => void; holdUpload = new Promise((resolve) => { release = resolve; }); const started = new Promise((resolve) => { uploadStarted = resolve; }); const pending = upload('/me/avatar-upload', token); await started; const asset = await db.asset.findFirstOrThrow({ where: { ownerUserId: id, purpose: 'avatar' }, }); assert.equal( (await request('/me', token, 'DELETE')).status, 200, ); await app.get(AssetService).cleanup(); assert.equal( ( await db.asset.findUniqueOrThrow({ where: { id: asset.id }, }) ).status, 'deleted', ); release(); assert.equal((await pending).status, 401); assert.equal( ( await db.asset.findUniqueOrThrow({ where: { id: asset.id }, }) ).status, 'deleting', ); holdUpload = undefined; uploadStarted = undefined; await app.get(AssetService).cleanup(); assert.equal( ( await db.asset.findUniqueOrThrow({ where: { id: asset.id }, }) ).status, 'deleted', ); }, ); await context.test( '纸张拆分:预演不写库、草稿幂等、独立发布/收藏/下载及历史兼容', async () => { const parent = await db.worksheet.create({ data: { id: 'paper-sheet', title: '作业纸', subtitle: '', categoryId, path: `${PAPER_SHEET_PATH}?id=paper-sheet`, ageMin: 3, ageMax: 12, difficulty: 1, previewAssetId: previewId, status: 'active', downloads: 123n, likes: 9n, }, }); await db.worksheetGrade.create({ data: { worksheetId: parent.id, grade: 0 }, }); const dry = await seedPaperSheets(app.get(PrismaService), true); assert.equal( dry.changes.filter((row) => row.action === 'insert-draft') .length, 10, ); assert.equal( await db.worksheet.count({ where: { rendererKey: 'paper-sheet' }, }), 0, ); await seedPaperSheets(app.get(PrismaService), false); assert.ok( ( await seedPaperSheets(app.get(PrismaService), false) ).changes.every((row) => row.action === 'unchanged'), ); assert.deepEqual( await db.worksheet.findUnique({ where: { id: parent.id } }), parent, ); for (const preset of PAPER_SHEET_PRESETS) { const draft = await db.worksheet.findUniqueOrThrow({ where: { id: preset.id }, }); assert.equal(draft.status, 'draft'); assert.equal(draft.firstPublishedAt, null); assert.equal(draft.downloads, 0n); assert.equal(draft.likes, 0n); assert.equal( (await request(`/worksheets/${preset.id}`)).status, 404, ); } const input = { id: 'paper-mi', title: '米字格', subtitle: '', categoryId, path: `${PAPER_SHEET_PATH}?id=paper-mi`, ageMin: 3, ageMax: 12, difficulty: 1, previewAssetId: previewId, tags: ['米字格'], status: 'active', }; assert.equal( ( await request( '/admin/worksheets/publish', tokens.admin, 'POST', { ...input, path: `${PAPER_SHEET_PATH}?id=paper-tian`, }, ) ).status, 400, ); assert.equal( ( await request( '/admin/worksheets/publish', tokens.admin, 'POST', input, ) ).status, 201, ); const detail = (await request('/worksheets/paper-mi')).body .data; assert.deepEqual(detail.rendering, { rendererKey: 'paper-sheet', paperType: 'mi', }); assert.equal(detail.renderConfig, undefined); assert.equal( (await request('/me/favorites/paper-mi', tokens.b, 'PUT')) .status, 200, ); assert.equal( ( await request( '/me/favorites/paper-sheet', tokens.b, 'PUT', ) ).status, 200, ); const download = await request( '/me/downloads', tokens.b, 'POST', { worksheetId: 'paper-mi', exportId: randomUUID() }, ); assert.equal(download.status, 201); const saved = await db.worksheet.findUniqueOrThrow({ where: { id: 'paper-mi' }, }); assert.equal(saved.downloads, 1n); assert.equal(saved.likes, 1n); const old = await db.worksheet.findUniqueOrThrow({ where: { id: parent.id }, }); assert.equal(old.downloads, 123n); assert.equal(old.likes, 10n); assert.ok( await db.favorite.findFirst({ where: { userId: ids.b, worksheetId: parent.id }, }), ); }, ); await context.test( '注销删除本人明细、撤销旧 JWT,其余用户不受影响', async () => { assert.equal( (await request('/me', tokens.a, 'DELETE')).status, 200, ); assert.equal( await db.favorite.count({ where: { userId: ids.a } }), 0, ); assert.equal( await db.downloadLog.count({ where: { userId: ids.a } }), 0, ); assert.equal( await db.user.findUnique({ where: { id: ids.a } }), null, ); assert.equal((await request('/me', tokens.a)).status, 401); assert.equal((await request('/me', tokens.b)).status, 200); assert.ok( await db.adminAuditLog.count({ where: { action: 'user.delete', resourceId: ids.a }, }), ); }, ); }, );