Files
doodle-store/apps/server/test/worksheet-database.test.ts

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),
);
});
},
);