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, PublicWorksheet, WorksheetPage, } from '@doodle/contracts'; import type { Prisma } from '../src/generated/prisma/client'; 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 { testConfig } from './test-config'; const databaseUrl = process.env.TEST_DATABASE_URL; test( '真实 PostgreSQL:公开题型组合筛选、分页、最新/热门和数据隔离', { skip: !databaseUrl }, async (context) => { const url = new URL(databaseUrl!); assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname)); assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/); const config = { ...testConfig, databaseUrl: databaseUrl!, qiniuPublicDomain: 'https://assets.example.test', }; 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 baseActive = await prisma.worksheet.count({ where: { status: 'active' }, }); await prisma.category.createMany({ data: [ { status: 'active', id: 'query-cat', name: '查询样本', icon: '📐', color: '#fff', }, { status: 'active', id: 'query-pages', name: '分页样本', icon: '📐', color: '#fff', }, ], }); await prisma.category.create({ data: { status: 'active', id: 'query-child', name: '子分类', icon: '📐', color: '#fff', parentId: 'query-cat', }, }); await prisma.asset.createMany({ data: [ { id: 'query-pending', purpose: 'preview', visibility: 'public', sourceRef: 'cloud://source/never-expose.jpg', }, { id: 'query-external', purpose: 'preview', visibility: 'public', provider: 'external', status: 'ready', mimeType: 'image/jpeg', externalUrl: 'https://external.example.test/preview.jpg', }, { id: 'query-private', purpose: 'preview', visibility: 'private', provider: 'external', status: 'ready', mimeType: 'image/jpeg', externalUrl: 'https://private.example.test/secret.jpg', }, { id: 'query-signed', purpose: 'preview', visibility: 'public', provider: 'external', status: 'ready', mimeType: 'image/jpeg', externalUrl: 'https://external.example.test/preview.jpg?sign=secret', }, { id: 'query-qiniu', purpose: 'preview', visibility: 'public', provider: 'qiniu', status: 'ready', mimeType: 'image/jpeg', bucket: 'test-public', objectKey: 'previews/中文 图片.jpg', objectRefHash: 'a'.repeat(64), checksum: 'b'.repeat(64), sizeBytes: 123n, }, { id: 'query-not-image', purpose: 'preview', visibility: 'public', provider: 'external', status: 'ready', mimeType: 'text/html', externalUrl: 'https://external.example.test/error.html', }, ], }); const old = new Date('2025-01-01T00:00:00Z'); const now = new Date(); const dayAgo = new Date(now.getTime() - 86400000); const fortyDaysAgo = new Date(now.getTime() - 40 * 86400000); const future = new Date(now.getTime() + 86400000); const create = ( id: string, extra: Partial = {}, ) => prisma.worksheet.create({ data: { id, title: id, subtitle: '', categoryId: 'query-cat', path: `/test?id=${id}`, ageMin: 3, ageMax: 8, difficulty: 1, previewAssetId: 'query-pending', tags: ['数学'], status: 'active', createdAt: old, updatedAt: old, renderConfig: { privateGenerator: 'never-expose-config' }, ...extra, }, }); await create('query-a', { grade: 23, isNew: true, isHot: true, sortOrder: 1, downloads: 10n, downloadsSeed: 90n, likes: 1n, updatedAt: now, tags: ['数学', '描红', 'chronology'], }); await create('query-b', { grade: 0, sortOrder: 1, firstPublishedAt: dayAgo, ageMax: 4, downloads: 40n, likes: 400n, previewAssetId: 'query-private', tags: ['数学', 'Math', 'chronology'], }); await create('query-c', { isHot: true, sortOrder: 2, firstPublishedAt: fortyDaysAgo, ageMin: 4, downloads: 50n, downloadsSeed: 60n, previewAssetId: 'query-external', tags: ['描红', 'chronology'], }); await create('query-future', { firstPublishedAt: future, previewAssetId: 'query-signed', }); await create('query-qiniu-item', { previewAssetId: 'query-qiniu', tags: ['image'], }); await create('query-html-item', { previewAssetId: 'query-not-image', tags: ['image'], }); await create('query-hidden', { status: 'hidden', downloads: 1000000n, firstPublishedAt: now, }); await create('query-draft', { status: 'draft' }); await create('query-child-item', { categoryId: 'query-child' }); await create('query-huge-one', { tags: ['precision'], downloads: 9223372036854775807n, }); await create('query-huge-two', { tags: ['precision'], downloads: 9223372036854775806n, downloadsSeed: 100n, }); await create('query-tie-a', { tags: ['tie'] }); await create('query-tie-b', { tags: ['tie'] }); await prisma.worksheetGrade.createMany({ data: [ { worksheetId: 'query-b', grade: 0 }, { worksheetId: 'query-b', grade: 1 }, { worksheetId: 'query-c', grade: 1 }, { worksheetId: 'query-c', grade: 2 }, { worksheetId: 'query-hidden', grade: 1 }, ], }); const response = (path: string) => fetch(`${origin}/api/v1/${path}`, { headers: { Authorization: 'Bearer invalid-public-token' }, signal: AbortSignal.timeout(10000), }); const list = async (query = ''): Promise => { const result = await response( `worksheets${query ? `?${query}` : ''}`, ); assert.equal(result.status, 200); const body = (await result.json()) as ApiSuccess; assert.equal(body.code, 0); return body.data; }; const ids = (data: WorksheetPage) => data.items.map((row) => row.id); const detail = async (id: string): Promise => { const result = await response(`worksheets/${id}`); assert.equal(result.status, 200); return ((await result.json()) as ApiSuccess).data; }; await context.test( '无需登录可查全部,固定 active,分类不自动包含子级,详情使用公开白名单', async () => { assert.equal((await list()).total, baseActive + 11); const filtered = await list( 'categoryId=query-cat&pageSize=100', ); assert.equal(filtered.total, 10); assert.equal(filtered.items.length, 10); assert.ok(!ids(filtered).includes('query-child-item')); assert.equal((await list('categoryId=not-found')).total, 0); for (const id of ['query-hidden', 'query-draft', 'missing']) assert.equal( (await response(`worksheets/${id}`)).status, 404, ); const row = await detail('query-a'); assert.equal(row.downloads, '100'); assert.equal(row.previewImg, null); assert.deepEqual(row.grades, []); assert.equal(row.isNew, false); for (const field of [ 'renderConfig', 'rendererKey', 'generationMode', 'previewAssetId', 'sourceRef', 'grade', 'downloadsSeed', 'assetKey', 'assetOwnerId', ]) assert.ok(!(field in row)); assert.ok(!JSON.stringify(filtered).includes('never-expose')); }, ); await context.test( '单/多年级使用审核关系,0 不丢失,重复年级与多重命中不产生重复行', async () => { assert.deepEqual( ids(await list('categoryId=query-cat&grade=0')), ['query-b'], ); const result = await list('categoryId=query-cat&grades=0,1,1'); assert.equal(result.total, 2); assert.deepEqual( new Set(ids(result)), new Set(['query-b', 'query-c']), ); assert.equal( (await list('categoryId=query-cat&grade=-4')).total, 0, ); assert.deepEqual((await detail('query-c')).grades, [1, 2]); }, ); await context.test( '年龄段要求完整覆盖且含边界,单年龄范围匹配;Tag 精确匹配并可组合其他条件', async () => { const band = await list( 'categoryId=query-cat&ageBand=3-4&pageSize=100', ); assert.ok( ids(band).includes('query-a') && ids(band).includes('query-b'), ); assert.ok(!ids(band).includes('query-c')); // 3~8 岁覆盖中间及最高年龄段;3~4 岁与 4~5 岁仅相交,不应命中。 for (const ageBand of ['4-5', '5-6', '7-8']) { const covered = await list( `categoryId=query-cat&ageBand=${ageBand}&pageSize=100`, ); assert.ok(ids(covered).includes('query-a')); assert.ok(ids(covered).includes('query-c')); assert.ok(!ids(covered).includes('query-b')); assert.equal(covered.total, covered.items.length); } assert.ok( ids( await list('categoryId=query-cat&age=4&pageSize=100'), ).includes('query-c'), ); assert.deepEqual( ids( await list( 'categoryId=query-cat&grade=1&ageBand=3-4&tag=数学', ), ), ['query-b'], ); assert.deepEqual( ids(await list('categoryId=query-cat&tag=Math')), ['query-b'], ); assert.equal( (await list('categoryId=query-cat&tag=math')).total, 0, ); assert.equal( ( await list( `categoryId=query-cat&tag=${encodeURIComponent("' OR TRUE --")}`, ) ).total, 0, ); }, ); await context.test( '最新按首发/创建时间,热门含 seed,运营推荐与真实新品筛选分开', async () => { assert.deepEqual( ids( await list( 'categoryId=query-cat&tag=chronology&sort=latest', ), ), ['query-b', 'query-c', 'query-a'], ); assert.deepEqual( ids( await list( 'categoryId=query-cat&tag=chronology&sort=hot', ), ), ['query-c', 'query-a', 'query-b'], ); assert.deepEqual( ids(await list('categoryId=query-cat&isHot=true')).sort(), ['query-a', 'query-c'], ); assert.deepEqual( ids(await list('categoryId=query-cat&isNew=true')), ['query-b'], ); const oldItems = ids( await list('categoryId=query-cat&isNew=false&pageSize=100'), ); assert.ok( oldItems.includes('query-a') && oldItems.includes('query-future'), ); assert.ok(!oldItems.includes('query-b')); const precise = await list( 'categoryId=query-cat&tag=precision&sort=hot', ); assert.deepEqual(ids(precise), [ 'query-huge-two', 'query-huge-one', ]); assert.equal( precise.items[0]?.downloads, '9223372036854775906', ); }, ); await context.test( '预览只解析 ready/public/image,无效、私有、签名或非图片不泄漏;七牛 key 正确编码', async () => { assert.equal((await detail('query-b')).previewImg, null); assert.equal((await detail('query-future')).previewImg, null); assert.equal( (await detail('query-html-item')).previewImg, null, ); assert.equal( (await detail('query-c')).previewImg, 'https://external.example.test/preview.jpg', ); assert.equal( (await detail('query-qiniu-item')).previewImg, 'https://assets.example.test/previews/%E4%B8%AD%E6%96%87%20%E5%9B%BE%E7%89%87.jpg', ); }, ); await context.test( '稳定分页可取超过 100 条,总数与筛选一致,越页为空;非法/管理参数被拒绝', async () => { const first = await list( 'categoryId=query-cat&tag=tie&pageSize=1', ); const second = await list( 'categoryId=query-cat&tag=tie&pageSize=1&page=2', ); assert.deepEqual(ids(first), ['query-tie-a']); assert.deepEqual(ids(second), ['query-tie-b']); assert.equal(first.hasNext, true); assert.equal(second.hasNext, false); await prisma.worksheet.createMany({ data: Array.from({ length: 121 }, (_, i) => ({ id: `query-page-${String(i).padStart(3, '0')}`, title: '分页题型', subtitle: '', categoryId: 'query-pages', path: '/test', ageMin: 3, ageMax: 5, difficulty: 1, previewAssetId: 'query-pending', status: 'active', createdAt: old, updatedAt: old, })), }); const pageOne = await list( 'categoryId=query-pages&pageSize=100', ); const pageTwo = await list( 'categoryId=query-pages&pageSize=100&page=2', ); assert.equal(pageOne.total, 121); assert.equal(pageOne.items.length, 100); assert.equal(pageTwo.items.length, 21); assert.equal( new Set([...ids(pageOne), ...ids(pageTwo)]).size, 121, ); assert.equal(pageTwo.hasNext, false); const beyond = await list('categoryId=query-pages&page=9'); assert.equal(beyond.total, 121); assert.deepEqual(beyond.items, []); for (const query of [ 'status=hidden', 'rawStats=true', 'grade=23', 'pageSize=101', 'ageBand=3-5', 'page=1&page=2', 'isHot=1', 'grades=1,,2', ]) assert.equal( (await response(`worksheets?${query}`)).status, 400, ); }, ); await context.test('Swagger 公共声明与查询参数已注册', async () => { const result = await fetch(`${origin}/api-docs-json`); const doc = (await result.json()) as { paths: Record< string, { get: { security: unknown[]; parameters: Array<{ name: string }>; }; } >; }; const route = doc.paths['/api/v1/worksheets']!.get; assert.deepEqual(route.security, []); for (const name of [ 'categoryId', 'grade', 'grades', 'ageBand', 'tag', 'sort', 'isHot', 'isNew', 'page', 'pageSize', ]) assert.ok( route.parameters.some((param) => param.name === name), ); }); }, );