Files
doodle-store/apps/server/test/member-site-database.test.ts
T
2026-10-23 16:52:47 +08:00

886 lines
33 KiB
TypeScript

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<void> | 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<NestExpressApplication>({
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<string, string> = {};
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<string, unknown>;
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<unknown>).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('<svg>not an image</svg>'),
)
).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<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((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 },
}),
);
},
);
},
);