524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
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<NestExpressApplication>({
|
|
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.WorksheetUncheckedCreateInput> = {},
|
|
) =>
|
|
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<WorksheetPage> => {
|
|
const result = await response(
|
|
`worksheets${query ? `?${query}` : ''}`,
|
|
);
|
|
assert.equal(result.status, 200);
|
|
const body = (await result.json()) as ApiSuccess<WorksheetPage>;
|
|
assert.equal(body.code, 0);
|
|
return body.data;
|
|
};
|
|
const ids = (data: WorksheetPage) => data.items.map((row) => row.id);
|
|
const detail = async (id: string): Promise<PublicWorksheet> => {
|
|
const result = await response(`worksheets/${id}`);
|
|
assert.equal(result.status, 200);
|
|
return ((await result.json()) as ApiSuccess<PublicWorksheet>).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),
|
|
);
|
|
});
|
|
},
|
|
);
|