740 lines
27 KiB
TypeScript
740 lines
27 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 { 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(
|
|
'注销删除本人明细、撤销旧 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 },
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|