feat: 完成分类、worksheet 接口开发,资源迁移到七牛

This commit is contained in:
R524809
2026-10-16 17:32:23 +08:00
parent e6ded00a4c
commit 8827110f2f
107 changed files with 12775 additions and 125 deletions
+1 -1
View File
@@ -264,7 +264,7 @@ test('HTTP 中间件、校验、日志、Swagger 与 JWT 全局守卫', async (c
assert.ok(!logs.join('\n').includes(userToken));
});
test('生产默认不暴露 Swagger;账户查询未接入时不放行有效 JWT', async (context) => {
test('生产默认不暴露 Swagger;真实账户数据库不可用时不放行有效 JWT', async (context) => {
const config = {
...testConfig,
nodeEnv: 'production' as const,
+402
View File
@@ -0,0 +1,402 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Controller, Get } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
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 { WECHAT_FETCH, type WechatFetch } from '../src/auth/wechat-client';
import { Roles } from '../src/auth/auth.decorators';
import { JwtTokenService } from '../src/auth/jwt-token.service';
import { AccountRepository } from '../src/auth/account.repository';
import { testConfig } from './test-config';
@Controller('test-admin')
class AdminProbe {
@Get()
@Roles('admin')
get() {
return { ok: true };
}
}
const databaseUrl = process.env.TEST_DATABASE_URL;
test(
'真实 PostgreSQL:微信登录、并发、本人隔离及账户状态鉴权',
{ skip: !databaseUrl },
async (context) => {
// 即使手工运行测试,也只允许本机脚本创建的短期库,禁止误用开发/生产数据库。
const parsed = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
const logs: string[] = [];
const logger = new AppLogger('info', (line) => logs.push(line));
const requests: string[] = [];
const http: WechatFetch = async (input) => {
const code = new URL(String(input)).searchParams.get('js_code')!;
requests.push(code);
if (code === 'bad-code')
return Response.json({
errcode: 40029,
errmsg: 'private-upstream-message',
});
if (code === 'risk-code') return Response.json({ errcode: 40226 });
if (code === 'quota-code') return Response.json({ errcode: 45011 });
if (code === 'network-code')
throw new Error('private-network-secret');
return Response.json({
openid: code.startsWith('concurrent-')
? 'ConcurrentOpenID'
: code,
session_key: 'private-session-key',
...(code === 'LegacyOpenID' ? { unionid: 'FreshUnion' } : {}),
});
};
const module = await Test.createTestingModule({
imports: [AppModule.forRoot(config)],
controllers: [AdminProbe],
})
.overrideProvider(WECHAT_FETCH)
.useValue(http)
.compile();
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 tokens = app.get(JwtTokenService);
const repository = app.get(AccountRepository);
const request = (path: string, token?: string) =>
fetch(`${origin}/api/v1/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: AbortSignal.timeout(10_000),
});
const login = (code: string, mode = 'start', extra: object = {}) =>
fetch(`${origin}/api/v1/auth/wechat/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forwarded-For': 'untrusted-client-ip',
},
body: JSON.stringify({ code, mode, ...extra }),
signal: AbortSignal.timeout(15_000),
});
const reason = async (
response: Response,
status: number,
expected: string,
) => {
assert.equal(response.status, status);
const body = (await response.json()) as {
reason: string;
requestId: string;
};
assert.equal(body.reason, expected);
assert.ok(body.requestId);
};
const loginData = async (response: Response) => {
assert.equal(response.status, 200);
assert.equal(response.headers.get('cache-control'), 'no-store');
return ((await response.json()) as ApiSuccess<WechatLoginResult>)
.data;
};
await context.test('初始配置和只读就绪检查可用', async () => {
assert.equal((await request('health/ready')).status, 200);
assert.equal(await prisma.pageConfig.count(), 1);
assert.equal(await prisma.setting.count(), 1);
assert.equal(await prisma.user.count(), 0);
await reason(await request('me'), 401, 'AUTH_REQUIRED');
});
await context.test(
'首次静默登录默认普通用户,续期不改变版本,响应只含白名单',
async () => {
const data = await loginData(await login('FirstOpenID'));
const claims = await tokens.verifyAccessToken(data.accessToken);
assert.equal(data.user.id, claims.sub);
assert.equal(data.expiresIn, 86400);
assert.equal(
data.expiresAt,
new Date(claims.exp * 1000).toISOString(),
);
assert.deepEqual(
Object.keys(data.user).sort(),
[
'id',
'nickName',
'avatarUrl',
'avatarUrlExpiresAt',
'createdAt',
].sort(),
);
const stored = await prisma.user.findUniqueOrThrow({
where: { id: data.user.id },
});
assert.equal(stored.role, 'user');
assert.equal(stored.status, 'active');
assert.equal(stored.authVersion, 1);
const second = await loginData(
await login('FirstOpenID', 'resume'),
);
assert.equal(second.user.id, data.user.id);
const me = await request(
'me?userId=somebody-else',
data.accessToken,
);
assert.equal(me.status, 200);
assert.equal(me.headers.get('cache-control'), 'no-store');
assert.equal(
((await me.json()) as { data: { id: string } }).data.id,
data.user.id,
);
assert.equal(
(await request('test-admin', data.accessToken)).status,
403,
);
},
);
await context.test(
'老 ID、资料、版本及时间保留,UnionID 只补空不覆盖',
async () => {
const createdAt = new Date('2025-01-01T00:00:00Z');
const updatedAt = new Date('2025-01-02T00:00:00Z');
await prisma.user.create({
data: {
id: 'legacy_cloud_id',
openid: 'LegacyOpenID',
nickName: '老用户',
authVersion: 3,
createdAt,
updatedAt,
},
});
const data = await loginData(
await login('LegacyOpenID', 'resume'),
);
assert.equal(data.user.id, 'legacy_cloud_id');
assert.equal(data.user.nickName, '老用户');
const stored = await prisma.user.findUniqueOrThrow({
where: { id: 'legacy_cloud_id' },
});
assert.equal(stored.unionid, 'FreshUnion');
assert.equal(stored.authVersion, 3);
assert.equal(
stored.createdAt.toISOString(),
createdAt.toISOString(),
);
await repository.login(
{ openid: 'LegacyOpenID', unionid: 'ConflictingUnion' },
'resume',
);
await repository.login(
{ openid: 'LegacyOpenID', unionid: null },
'resume',
);
const after = await prisma.user.findUniqueOrThrow({
where: { id: 'legacy_cloud_id' },
});
assert.equal(after.unionid, 'FreshUnion');
assert.equal(
after.updatedAt.toISOString(),
stored.updatedAt.toISOString(),
);
assert.ok(after.lastActiveAt > createdAt);
},
);
await context.test(
'并发首次登录只生成一个账户,OpenID 区分大小写',
async () => {
const responses = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
login(`concurrent-${i}`),
),
);
const users = await Promise.all(
responses.map(async (r) => (await loginData(r)).user.id),
);
assert.equal(new Set(users).size, 1);
assert.equal(
await prisma.user.count({
where: { openid: 'ConcurrentOpenID' },
}),
1,
);
const lower = await repository.login(
{ openid: 'concurrentopenid', unionid: null },
'start',
);
assert.notEqual(lower.account.id, users[0]);
},
);
await context.test(
'resume 不建号,禁用不能被 start 重置,撤销/降权/删除即时生效',
async () => {
await reason(
await login('MissingOpenID', 'resume'),
401,
'ACCOUNT_NOT_FOUND',
);
assert.equal(
await prisma.user.count({
where: { openid: 'MissingOpenID' },
}),
0,
);
const data = await loginData(await login('LifecycleOpenID'));
const id = data.user.id;
await prisma.user.update({
where: { id },
data: { role: 'admin' },
});
assert.equal(
(await request('test-admin', data.accessToken)).status,
200,
);
await prisma.user.update({
where: { id },
data: { role: 'user' },
});
assert.equal(
(await request('test-admin', data.accessToken)).status,
403,
);
await prisma.user.update({
where: { id },
data: { authVersion: { increment: 1 } },
});
await reason(
await request('me', data.accessToken),
401,
'AUTH_REVOKED',
);
const renewed = await loginData(
await login('LifecycleOpenID', 'resume'),
);
await prisma.user.update({
where: { id },
data: { status: 'disabled' },
});
await reason(
await request('me', renewed.accessToken),
401,
'ACCOUNT_DISABLED',
);
await reason(
await login('LifecycleOpenID'),
401,
'ACCOUNT_DISABLED',
);
await prisma.user.delete({ where: { id } });
await reason(
await request('me', renewed.accessToken),
401,
'ACCOUNT_NOT_FOUND',
);
await reason(
await login('LifecycleOpenID', 'resume'),
401,
'ACCOUNT_NOT_FOUND',
);
const fresh = await loginData(await login('LifecycleOpenID'));
assert.notEqual(fresh.user.id, id);
},
);
await context.test(
'拒绝额外身份参数和坏 code,错误不变成成功响应',
async () => {
const before = requests.length;
for (const input of [
{ openid: 'forged' },
{ appid: 'forged' },
{ userId: 'forged' },
{ role: 'admin' },
])
assert.equal(
(await login('valid', 'start', input)).status,
400,
);
assert.equal((await login(' ', 'start')).status, 400);
assert.equal((await login('valid', 'unknown')).status, 400);
assert.equal(requests.length, before);
await reason(
await login('bad-code'),
400,
'WECHAT_CODE_INVALID',
);
await reason(
await login('risk-code'),
403,
'WECHAT_LOGIN_BLOCKED',
);
const quota = await login('quota-code');
assert.equal(quota.headers.get('retry-after'), '60');
await reason(quota, 429, 'RATE_LIMITED');
await reason(
await login('network-code'),
503,
'AUTH_UPSTREAM_UNAVAILABLE',
);
},
);
await context.test(
'认证失败原因及日志不泄露凭据,伪造转发头不能绕过限流',
async () => {
await reason(
await request('me', 'invalid-token'),
401,
'AUTH_INVALID',
);
const jwt = app.get(JwtTokenService);
const expired = await app
.get((await import('@nestjs/jwt')).JwtService)
.signAsync(
{ sub: 'legacy_cloud_id', authVersion: 3 },
{
secret: config.jwtSecret,
issuer: config.jwtIssuer,
audience: config.jwtAudience,
expiresIn: -1,
},
);
await reason(await request('me', expired), 401, 'AUTH_EXPIRED');
const token = await jwt.signAccessToken('legacy_cloud_id', 3);
assert.equal((await request('me', token)).status, 200);
let limited = false;
for (let i = 0; i < 35; i++) {
const result = await login(`rate-${i}`, 'resume');
if (result.status === 429) {
assert.ok(result.headers.get('retry-after'));
limited = true;
break;
}
}
assert.ok(limited);
const allLogs = logs.join('\n');
for (const secret of [
config.wechatMiniAppSecret,
'private-session-key',
'private-upstream-message',
'private-network-secret',
token,
'LegacyOpenID',
])
assert.ok(!allLogs.includes(secret));
},
);
},
);
+731
View File
@@ -0,0 +1,731 @@
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, Category } from '@doodle/contracts';
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 { CategoryService } from '../src/categories/category.service';
import { testConfig } from './test-config';
const databaseUrl = process.env.TEST_DATABASE_URL;
const item = {
status: 'active' as const,
id: 'math',
name: '数感启蒙',
icon: '📐',
color: '#CDB4DB',
sortOrder: 0,
parentId: null,
};
test(
'真实 PostgreSQL:分类接口、并发树校验、审计及幂等导入',
{ skip: !databaseUrl },
async (context) => {
const parsed = new URL(databaseUrl!);
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
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 service = app.get(CategoryService);
const tokens = app.get(JwtTokenService);
await prisma.user.createMany({
data: [
{
id: 'category-admin',
openid: 'category-admin',
role: 'admin',
},
{
id: 'category-admin-two',
openid: 'category-admin-two',
role: 'admin',
},
{ id: 'category-user', openid: 'category-user' },
],
});
const admin = await tokens.signAccessToken('category-admin', 1);
const secondAdmin = await tokens.signAccessToken(
'category-admin-two',
1,
);
const user = await tokens.signAccessToken('category-user', 1);
const call = (
path: string,
method = 'GET',
body?: unknown,
token?: string,
) =>
fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': 'category-test-request',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(15_000),
});
const data = async (response: Response, status = 200) => {
assert.equal(response.status, status);
const body = (await response.json()) as ApiSuccess<Category>;
assert.equal(body.code, 0);
return body.data;
};
await context.test(
'公开列表无需登录,写入限定管理员,校验失败不写库',
async () => {
const initial = await call('categories');
assert.equal(initial.status, 200);
assert.deepEqual(
((await initial.json()) as ApiSuccess<Category[]>).data,
[],
);
for (const [path, method, body] of [
['admin/categories', 'POST', item],
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
['admin/categories/math', 'DELETE', undefined],
] as const) {
assert.equal((await call(path, method, body)).status, 401);
assert.equal(
(await call(path, method, body, user)).status,
403,
);
}
for (const input of [
{ ...item, name: null },
{ ...item, sortOrder: '0' },
{ ...item, unexpected: true },
{ ...item, parentId: 'missing' },
])
assert.equal(
(await call('admin/categories', 'POST', input, admin))
.status,
400,
);
assert.equal(await prisma.category.count(), 0);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceType: 'categories' },
}),
0,
);
},
);
await context.test(
'创建、公开详情、稳定排序、局部更新及同事务审计',
async () => {
const created = await data(
await call('admin/categories', 'POST', item, admin),
201,
);
assert.equal(created.id, 'math');
assert.equal(created.icon, '📐');
assert.equal(created.parentId, null);
assert.deepEqual(
Object.keys(created).sort(),
[
'status',
'id',
'name',
'icon',
'color',
'sortOrder',
'parentId',
'createdAt',
'updatedAt',
].sort(),
);
assert.equal(
(await call('admin/categories', 'POST', item, admin))
.status,
409,
);
assert.equal((await call('categories/missing')).status, 404);
assert.equal(
(await data(await call('categories/math'))).name,
item.name,
);
await data(
await call(
'admin/categories',
'POST',
{ ...item, id: 'Math' },
admin,
),
201,
);
await data(
await call(
'admin/categories',
'POST',
{
...item,
id: 'child',
parentId: 'math',
sortOrder: 2,
},
admin,
),
201,
);
const list = await call('categories');
assert.deepEqual(
((await list.json()) as ApiSuccess<Category[]>).data.map(
(row) => row.id,
),
['Math', 'math', 'child'],
);
const updated = await data(
await call(
'admin/categories/math',
'PATCH',
{ name: '数学启蒙' },
admin,
),
);
assert.equal(updated.name, '数学启蒙');
assert.equal(updated.icon, item.icon);
assert.equal(updated.createdAt, created.createdAt);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: {
resourceType: 'categories',
resourceId: 'math',
action: 'category.update',
},
});
assert.equal(audit.actorUserId, 'category-admin');
assert.equal(audit.requestId, 'category-test-request');
assert.equal(
(audit.beforeData as { name: string }).name,
item.name,
);
assert.equal(
(audit.afterData as { name: string }).name,
updated.name,
);
const count = await prisma.adminAuditLog.count();
const unchanged = await data(
await call(
'admin/categories/math',
'PATCH',
{ name: updated.name },
admin,
),
);
assert.equal(unchanged.updatedAt, updated.updatedAt);
assert.equal(await prisma.adminAuditLog.count(), count);
for (const patch of [
{},
{ id: 'new-id' },
{ icon: null },
{ parentId: 'child' },
{ parentId: 'math' },
])
assert.equal(
(
await call(
'admin/categories/math',
'PATCH',
patch,
admin,
)
).status,
400,
);
await data(
await call(
'admin/categories/child',
'PATCH',
{ parentId: '' },
admin,
),
);
assert.equal(
(await data(await call('categories/child'))).parentId,
null,
);
},
);
await context.test(
'不同管理员并发互设父级只能成功一个,事务内再次检查账户',
async () => {
const results = await Promise.all([
call(
'admin/categories/math',
'PATCH',
{ parentId: 'Math' },
admin,
),
call(
'admin/categories/Math',
'PATCH',
{ parentId: 'math' },
secondAdmin,
),
]);
assert.deepEqual(
results.map((r) => r.status).sort(),
[200, 400],
);
await prisma.user.update({
where: { id: 'category-admin' },
data: { role: 'user' },
});
await assert.rejects(
service.create(
{ ...item, id: 'forged' },
{
userId: 'category-admin',
authVersion: 1,
role: 'admin',
},
'recheck',
),
);
const staleActor = {
userId: 'category-admin',
authVersion: 1,
role: 'admin' as const,
};
await assert.rejects(
service.update(
'math',
{ name: '无权限修改' },
staleActor,
'recheck',
),
{ status: 403 },
);
await assert.rejects(
service.remove('math', staleActor, 'recheck'),
{ status: 403 },
);
assert.equal(
await prisma.category.count({ where: { id: 'forged' } }),
0,
);
await prisma.user.update({
where: { id: 'category-admin' },
data: { role: 'admin' },
});
},
);
await context.test(
'管理员删除无引用分类,审计保留删除前快照;重复删除返回 404',
async () => {
const id = 'delete-leaf';
const before = await data(
await call(
'admin/categories',
'POST',
{ ...item, id },
admin,
),
201,
);
const response = await call(
`admin/categories/${id}`,
'DELETE',
undefined,
admin,
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
code: 0,
message: 'ok',
data: { id },
});
assert.equal((await call(`categories/${id}`)).status, 404);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: { resourceId: id, action: 'category.delete' },
});
assert.equal(audit.actorUserId, 'category-admin');
assert.equal(audit.actorType, 'admin');
assert.equal(audit.requestId, 'category-test-request');
assert.equal(
(audit.beforeData as { name: string }).name,
before.name,
);
assert.equal(audit.afterData, null);
assert.equal(
(
await call(
`admin/categories/${id}`,
'DELETE',
undefined,
admin,
)
).status,
404,
);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceId: id, action: 'category.delete' },
}),
1,
);
},
);
await context.test(
'有子分类或任何状态题型引用时返回 409,不删除关联内容或记录成功审计',
async () => {
await prisma.category.createMany({
data: [
{ ...item, id: 'delete-parent' },
{ ...item, id: 'delete-used' },
],
});
await prisma.category.create({
data: {
...item,
id: 'delete-child',
parentId: 'delete-parent',
},
});
await prisma.asset.create({
data: {
id: 'delete-test-preview',
purpose: 'preview',
visibility: 'public',
},
});
await prisma.worksheet.create({
data: {
id: 'delete-test-worksheet',
title: '删除保护测试',
subtitle: '',
categoryId: 'delete-used',
path: '/test',
ageMin: 3,
ageMax: 6,
difficulty: 1,
previewAssetId: 'delete-test-preview',
rendererKey: 'test',
},
});
assert.equal(
(
await call(
'admin/categories/delete-parent',
'DELETE',
undefined,
admin,
)
).status,
409,
);
for (const status of ['draft', 'active', 'hidden']) {
await prisma.worksheet.update({
where: { id: 'delete-test-worksheet' },
data: { status },
});
assert.equal(
(
await call(
'admin/categories/delete-used',
'DELETE',
undefined,
admin,
)
).status,
409,
);
}
assert.equal(
await prisma.category.count({
where: {
id: {
in: [
'delete-parent',
'delete-child',
'delete-used',
],
},
},
}),
3,
);
assert.equal(
await prisma.worksheet.count({
where: { id: 'delete-test-worksheet' },
}),
1,
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: {
in: ['delete-parent', 'delete-used'],
},
action: 'category.delete',
},
}),
0,
);
},
);
await context.test(
'审计失败会回滚删除,并发删除只成功一次',
async () => {
await prisma.category.createMany({
data: [
{ ...item, id: 'rollback-delete' },
{ ...item, id: 'concurrent-delete' },
],
});
// 在隔离测试库制造审计失败,验证删行与审计确实处在同一事务。
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs ADD CONSTRAINT category_delete_audit_test CHECK (action <> 'category.delete' OR resource_id <> 'rollback-delete')`;
try {
assert.equal(
(
await call(
'admin/categories/rollback-delete',
'DELETE',
undefined,
admin,
)
).status,
503,
);
assert.equal(
await prisma.category.count({
where: { id: 'rollback-delete' },
}),
1,
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'rollback-delete',
action: 'category.delete',
},
}),
0,
);
} finally {
await prisma.$executeRaw`ALTER TABLE public.admin_audit_logs DROP CONSTRAINT category_delete_audit_test`;
}
const responses = await Promise.all([
call(
'admin/categories/concurrent-delete',
'DELETE',
undefined,
admin,
),
call(
'admin/categories/concurrent-delete',
'DELETE',
undefined,
secondAdmin,
),
]);
assert.deepEqual(
responses.map((response) => response.status).sort(),
[200, 404],
);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'concurrent-delete',
action: 'category.delete',
},
}),
1,
);
},
);
await context.test(
'禁用、降权、凭证撤销后,新增/修改/删除都立即失去权限',
async () => {
const actions = [
[
'admin/categories',
'POST',
{ ...item, id: 'forbidden-create' },
],
['admin/categories/math', 'PATCH', { name: '无权限修改' }],
['admin/categories/math', 'DELETE', undefined],
] as const;
const check = async (status: number) => {
for (const [path, method, body] of actions)
assert.equal(
(await call(path, method, body, secondAdmin))
.status,
status,
);
};
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { status: 'disabled' },
});
await check(401);
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { status: 'active', role: 'user' },
});
await check(403);
await prisma.user.update({
where: { id: 'category-admin-two' },
data: { role: 'admin', authVersion: { increment: 1 } },
});
await check(401);
assert.equal(
await prisma.category.count({
where: { id: 'forbidden-create' },
}),
0,
);
assert.ok(
await prisma.category.findUnique({ where: { id: 'math' } }),
);
},
);
await context.test(
'子先父后导入、预演不写库、缺时报告、重导不刷新时间/审计',
async () => {
const rows = [
{
...item,
_id: 'import-child',
id: 'import-child',
parentId: 'import-root',
},
{ ...item, id: 'import-root' },
];
const source = rows
.map((row) => JSON.stringify(row))
.join('\n');
const count = await prisma.category.count();
const audits = await prisma.adminAuditLog.count();
const preview = await service.importExport(
source,
true,
'dry-run',
'test',
);
assert.equal(preview.inserted, 2);
assert.equal(preview.missingCreatedAt, 2);
assert.equal(await prisma.category.count(), count);
assert.equal(await prisma.adminAuditLog.count(), audits);
const applied = await service.importExport(
source,
false,
'import-test',
'sha256=test',
);
assert.equal(applied.inserted, 2);
const before = await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
});
assert.equal(before.createdAt.toISOString(), applied.batchAt);
const repeated = await service.importExport(
source,
false,
'repeat',
'test',
);
assert.equal(repeated.unchanged, 2);
assert.equal(repeated.inserted + repeated.updated, 0);
assert.deepEqual(
await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
}),
before,
);
assert.equal(await prisma.adminAuditLog.count(), audits + 2);
const updated = await service.importExport(
JSON.stringify([
{ ...item, id: 'import-root', name: '新名称' },
]),
false,
'import-update',
'test',
);
assert.equal(updated.updated, 1);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'import-root' },
})
).createdAt.toISOString(),
before.createdAt.toISOString(),
);
},
);
await context.test(
'导入后半批失败会回滚已写行及审计,且校验现有树中的循环',
async () => {
const count = await prisma.category.count();
const audits = await prisma.adminAuditLog.count();
await assert.rejects(
service.importExport(
JSON.stringify([
{ ...item, id: 'rollback-first' },
{
...item,
id: 'rollback-second',
createdAt: '2099-01-01T00:00:00Z',
},
]),
false,
'rollback',
'test',
),
);
assert.equal(await prisma.category.count(), count);
assert.equal(await prisma.adminAuditLog.count(), audits);
await assert.rejects(
service.importExport(
JSON.stringify([
{
...item,
id: 'import-root',
parentId: 'import-child',
},
]),
false,
'cycle',
'test',
),
);
await assert.rejects(
service.importExport(
JSON.stringify([
{ ...item, id: 'orphan', parentId: 'not-found' },
]),
false,
'orphan',
'test',
),
);
},
);
},
);
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { categoryTreeOrder } from '../src/categories/category-input';
import { parseCategoryExport } from '../src/categories/category-import';
const root = {
_id: 'math',
name: '数感启蒙',
icon: '📐',
color: '#CDB4DB',
sortOrder: 0,
parentId: null,
};
test('分类导入:兼容 JSON Lines/数组,保留原 ID、Emoji 和带时区时间', () => {
const child = {
...root,
_id: 'Math',
parentId: 'math',
createdAt: '2025-01-01T08:00:00+08:00',
};
const lines = `\uFEFF${JSON.stringify(child)}\n\n${JSON.stringify(root)}\n`;
const rows = parseCategoryExport(lines);
assert.deepEqual(
rows.map((row) => row.id),
['Math', 'math'],
);
assert.equal(rows[0]?.createdAt?.toISOString(), '2025-01-01T00:00:00.000Z');
assert.equal(rows[1]?.createdAt, undefined);
assert.equal(rows[1]?.icon, '📐');
assert.deepEqual(categoryTreeOrder(rows), ['math', 'Math']);
assert.equal(
parseCategoryExport(JSON.stringify([{ ...root, parentId: '' }]))[0]
?.parentId,
null,
);
});
test('分类导入:拒绝重复、未知字段、类型转换、越界及未迁移文件图标', () => {
const bad = [
[{ ...root, id: 'other' }],
[root, root],
[{ ...root, role: 'admin' }],
[{ ...root, name: 42 }],
[{ ...root, icon: 'https://private.example/icon.png' }],
[{ ...root, icon: 'cloud://secret/icon' }],
[{ ...root, _id: ' math' }],
[{ ...root, sortOrder: '2' }],
[{ ...root, sortOrder: 0.5 }],
[{ ...root, sortOrder: 2147483648 }],
[{ ...root, parentId: false }],
[{ ...root, createdAt: '2025-01-01T00:00:00' }],
[{ ...root, createdAt: { $date: '2025-01-01' } }],
[
{
...root,
createdAt: '2025-01-02T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
],
];
for (const value of bad)
assert.throws(() => parseCategoryExport(JSON.stringify(value)));
for (const value of ['', '{}', '[]', '{broken'])
assert.throws(() => parseCategoryExport(value));
});
test('分类树:缺失父级、自引用和多层循环均拒绝,深层树不递归', () => {
for (const rows of [
[{ id: 'a', parentId: 'missing' }],
[{ id: 'a', parentId: 'a' }],
[
{ id: 'a', parentId: 'b' },
{ id: 'b', parentId: 'c' },
{ id: 'c', parentId: 'a' },
],
])
assert.throws(() => categoryTreeOrder(rows));
const deep = Array.from({ length: 10_000 }, (_, i) => ({
id: String(i),
parentId: i ? String(i - 1) : null,
})).reverse();
assert.equal(categoryTreeOrder(deep).length, 10_000);
});
@@ -0,0 +1,725 @@
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 {
AdminWorksheet,
AdminWorksheetResource,
ContentPublication,
} from '@doodle/contracts';
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 { PublicationService } from '../src/content/publication.service';
import { CategoryService } from '../src/categories/category.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(['localhost', '127.0.0.1'].includes(url.hostname));
assert.match(url.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
const config = { ...testConfig, databaseUrl: databaseUrl! };
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 tokens = app.get(JwtTokenService);
await prisma.user.createMany({
data: [
{
id: 'publication-admin',
openid: 'publication-admin',
role: 'admin',
},
{ id: 'publication-user', openid: 'publication-user' },
],
});
const admin = await tokens.signAccessToken('publication-admin', 1);
const user = await tokens.signAccessToken('publication-user', 1);
const actor = {
userId: 'publication-admin',
authVersion: 1,
role: 'admin' as const,
};
const call = (
path: string,
method = 'GET',
body?: unknown,
token?: string,
) =>
fetch(`${origin}/api/v1/${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': 'publication-test',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(15_000),
});
async function data<T>(path: string, token?: string): Promise<T> {
const response = await call(path, 'GET', undefined, token);
assert.equal(
response.status,
200,
`${path}: ${await (response.status === 200 ? Promise.resolve('') : response.text())}`,
);
return ((await response.json()) as { data: T }).data;
}
async function patch<T = ContentPublication>(
path: string,
status: string,
): Promise<T> {
const response = await call(path, 'PATCH', { status }, admin);
assert.equal(response.status, 200);
return ((await response.json()) as { data: T }).data;
}
const category = { name: '发布样本', icon: '📄', color: '#fff' };
await prisma.category.create({
data: { ...category, id: 'pub-root', status: 'active' },
});
await prisma.category.create({
data: {
...category,
id: 'pub-child',
parentId: 'pub-root',
status: 'active',
},
});
await prisma.category.create({
data: {
...category,
id: 'pub-grandchild',
parentId: 'pub-child',
status: 'active',
},
});
await prisma.category.create({
data: { ...category, id: 'pub-draft-category' },
});
await prisma.asset.create({
data: {
id: 'pub-asset',
purpose: 'preview',
visibility: 'public',
sourceRef: 'cloud://never-public/source',
},
});
const old = new Date('2024-01-01T00:00:00Z');
for (const [id, status, categoryId] of [
['pub-live', 'active', 'pub-grandchild'],
['pub-draft', 'draft', 'pub-grandchild'],
['pub-unready', 'draft', 'pub-draft-category'],
['pub-old', 'hidden', 'pub-grandchild'],
['pub-old-hidden', 'hidden', 'pub-grandchild'],
['pub-never-published', 'draft', 'pub-grandchild'],
['pub-rollback', 'draft', 'pub-grandchild'],
]) {
await prisma.worksheet.create({
data: {
id: id!,
title: id!,
subtitle: '',
categoryId: categoryId!,
status: status!,
path: '/test',
ageMin: 3,
ageMax: 6,
difficulty: 1,
previewAssetId: 'pub-asset',
downloads: 1000000n,
tags: ['publication'],
createdAt: old,
},
});
}
await prisma.worksheetResource.createMany({
data: [
{
id: 'pub-resource',
worksheetId: 'pub-live',
title: '资源草稿',
previewAssetId: 'pub-asset',
},
{
id: 'pub-resource-unready',
worksheetId: 'pub-draft',
title: '上级未发布',
previewAssetId: 'pub-asset',
},
],
});
await context.test(
'新分类默认草稿,公开列表/详情隐藏;管理读取仅限当前管理员',
async () => {
const response = await call(
'admin/categories',
'POST',
{ ...category, id: 'pub-new' },
admin,
);
assert.equal(response.status, 201);
assert.equal(
((await response.json()) as { data: { status: string } })
.data.status,
'draft',
);
assert.equal((await call('categories/pub-new')).status, 404);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-draft-category' },
})
).status,
'draft',
);
const rows = await data<Array<{ id: string }>>('categories');
assert.ok(!rows.some((row) => row.id === 'pub-new'));
for (const path of [
'admin/categories',
'admin/categories/pub-new',
'admin/worksheets?status=draft',
'admin/worksheets/pub-draft',
'admin/worksheet-resources',
'admin/worksheet-resources/pub-resource',
]) {
assert.equal((await call(path)).status, 401);
assert.equal(
(await call(path, 'GET', undefined, user)).status,
403,
);
assert.equal(
(await call(path, 'GET', undefined, admin)).status,
200,
);
}
const preview = await data<AdminWorksheet>(
'admin/worksheets/pub-draft',
admin,
);
assert.equal(preview.status, 'draft');
assert.equal(preview.visible, false);
assert.equal(preview.previewImg, null);
for (const field of [
'renderConfig',
'assetKey',
'sourceRef',
'downloadsSeed',
])
assert.ok(!(field in preview));
const drafts = await data<{ items: AdminWorksheet[] }>(
'admin/worksheets?status=draft&tag=publication',
admin,
);
assert.ok(drafts.items.some((row) => row.id === 'pub-draft'));
assert.ok(drafts.items.every((row) => row.status === 'draft'));
for (const bad of [null, 'testing', true, 'ACTIVE']) {
assert.equal(
(
await call(
'admin/categories/pub-new',
'PATCH',
{ status: bad },
admin,
)
).status,
400,
);
assert.equal(
(
await call(
'admin/worksheets/pub-draft/status',
'PATCH',
{ status: bad },
admin,
)
).status,
400,
);
}
assert.equal(
(
await call(
'admin/worksheet-resources?status=testing',
'GET',
undefined,
admin,
)
).status,
400,
);
assert.equal(
(
await call(
'admin/worksheet-resources?grade=1',
'GET',
undefined,
admin,
)
).status,
400,
);
},
);
await context.test(
'多层祖先下架影响全部/分类/热门/筛选/详情,恢复不改写下属状态',
async () => {
assert.equal((await call('worksheets/pub-live')).status, 200);
await patch('admin/categories/pub-root', 'hidden');
for (const id of ['pub-root', 'pub-child', 'pub-grandchild'])
assert.equal((await call(`categories/${id}`)).status, 404);
for (const query of [
'tag=publication',
'categoryId=pub-grandchild',
'tag=publication&sort=hot',
'tag=publication&sort=latest',
'tag=publication&age=4',
'tag=publication&ageBand=3-4',
]) {
const result = await data<{
total: number;
items: unknown[];
}>(`worksheets?${query}`);
assert.equal(result.total, 0);
assert.deepEqual(result.items, []);
}
assert.equal((await call('worksheets/pub-live')).status, 404);
assert.equal(
(
await data<AdminWorksheet>(
'admin/worksheets/pub-live',
admin,
)
).visible,
false,
);
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-live' },
})
).status,
'active',
);
await patch('admin/categories/pub-root', 'active');
assert.equal((await call('worksheets/pub-live')).status, 200);
assert.equal((await call('worksheets/pub-draft')).status, 404);
assert.equal(
(await call('worksheets?status=draft')).status,
400,
);
assert.equal(
(await call('worksheets?preview=true')).status,
400,
);
assert.equal(
(await call('categories/pub-draft-category?preview=true'))
.status,
404,
);
},
);
await context.test(
'状态修改鉴权、上级发布前置条件与资源有效可见性',
async () => {
for (const path of [
'admin/worksheets/pub-draft/status',
'admin/worksheet-resources/pub-resource/status',
]) {
assert.equal(
(await call(path, 'PATCH', { status: 'active' }))
.status,
401,
);
assert.equal(
(await call(path, 'PATCH', { status: 'active' }, user))
.status,
403,
);
}
assert.equal(
(
await call(
'admin/worksheets/pub-unready/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
409,
);
assert.equal(
(
await call(
'admin/worksheet-resources/pub-resource-unready/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
409,
);
const published = await patch(
'admin/worksheet-resources/pub-resource/status',
'active',
);
assert.ok(published.firstPublishedAt);
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
true,
);
await patch('admin/worksheets/pub-live/status', 'hidden');
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
false,
);
assert.equal(
(
await prisma.worksheetResource.findUniqueOrThrow({
where: { id: 'pub-resource' },
})
).status,
'active',
);
await patch('admin/worksheets/pub-live/status', 'active');
assert.equal(
(
await data<AdminWorksheetResource>(
'admin/worksheet-resources/pub-resource',
admin,
)
).visible,
true,
);
const resources = await data<{
total: number;
items: AdminWorksheetResource[];
}>(
'admin/worksheet-resources?worksheetId=pub-live&status=active&pageSize=1',
admin,
);
assert.equal(resources.total, 1);
assert.equal(resources.items[0]?.id, 'pub-resource');
},
);
await context.test(
'首次发布登记、重发保持时间、未知旧首发不伪造,并发重复仅一份审计',
async () => {
const paths = [
'admin/worksheets/pub-draft/status',
'admin/worksheet-resources/pub-resource/status',
];
const first = await patch(paths[0]!, 'active');
assert.ok(first.firstPublishedAt);
for (const path of paths) {
const before = await data<ContentPublication>(
path.replace('/status', ''),
admin,
);
await patch(path, 'hidden');
const after = await patch(path, 'active');
assert.equal(
after.firstPublishedAt,
before.firstPublishedAt,
);
}
assert.equal(
(await patch('admin/worksheets/pub-old/status', 'active'))
.firstPublishedAt,
null,
);
await patch('admin/worksheets/pub-old-hidden/status', 'draft');
assert.equal(
(
await patch(
'admin/worksheets/pub-old-hidden/status',
'active',
)
).firstPublishedAt,
null,
);
await patch('admin/worksheets/pub-old/status', 'draft');
assert.equal(
(await patch('admin/worksheets/pub-old/status', 'active'))
.firstPublishedAt,
null,
);
await patch(
'admin/worksheets/pub-never-published/status',
'hidden',
);
await patch(
'admin/worksheets/pub-never-published/status',
'draft',
);
assert.ok(
(
await patch(
'admin/worksheets/pub-never-published/status',
'active',
)
).firstPublishedAt,
);
const audits = await prisma.adminAuditLog.count({
where: {
resourceId: 'pub-draft',
action: 'worksheet.status',
},
});
const results = await Promise.all([
patch(paths[0]!, 'hidden'),
patch(paths[0]!, 'hidden'),
]);
assert.deepEqual(results[0], results[1]);
assert.equal(
await prisma.adminAuditLog.count({
where: {
resourceId: 'pub-draft',
action: 'worksheet.status',
},
}),
audits + 1,
);
const stored = await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-draft' },
});
await patch(paths[0]!, 'hidden');
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-draft' },
})
).updatedAt.toISOString(),
stored.updatedAt.toISOString(),
);
},
);
await context.test(
'状态变更同事务撤销页面快照;无变更不增加版本',
async () => {
await prisma.pageConfig.create({
data: {
id: 'publication-snapshot',
home: { featured: [{ id: 'pub-live' }] },
category: { test: true },
age: { test: true },
version: 9n,
publishedVersion: 9n,
publishedAt: new Date(),
},
});
await patch('admin/categories/pub-root', 'hidden');
const snapshot = await prisma.pageConfig.findUniqueOrThrow({
where: { id: 'publication-snapshot' },
});
assert.equal(snapshot.version, 10n);
for (const key of [
'home',
'category',
'age',
'publishedVersion',
'publishedAt',
] as const)
assert.equal(snapshot[key], null);
await patch('admin/categories/pub-root', 'hidden');
assert.deepEqual(
await prisma.pageConfig.findUniqueOrThrow({
where: { id: 'publication-snapshot' },
}),
snapshot,
);
await patch('admin/categories/pub-root', 'active');
},
);
await context.test(
'旧分类导出省略 status 时重导保留下架,新导入分类默认草稿',
async () => {
const service = app.get(CategoryService);
await patch('admin/categories/pub-child', 'hidden');
const before = await prisma.category.findUniqueOrThrow({
where: { id: 'pub-child' },
});
const report = await service.importExport(
JSON.stringify([
{
...category,
id: 'pub-child',
parentId: 'pub-root',
sortOrder: 0,
},
]),
false,
'publication-import',
'test',
);
assert.equal(report.unchanged, 1);
assert.deepEqual(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-child' },
}),
before,
);
await service.importExport(
JSON.stringify([{ ...category, id: 'pub-import-draft' }]),
false,
'publication-import',
'test',
);
assert.equal(
(
await prisma.category.findUniqueOrThrow({
where: { id: 'pub-import-draft' },
})
).status,
'draft',
);
await patch('admin/categories/pub-child', 'active');
},
);
await context.test(
'审计失败回滚状态、首发时间与快照;服务层复核禁用、降权和版本失效',
async () => {
const snapshots = await prisma.pageConfig.findMany();
await prisma.$executeRawUnsafe(
`CREATE FUNCTION fail_publication_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.resource_id = 'pub-rollback' THEN RAISE EXCEPTION 'test rollback'; END IF; RETURN NEW; END $$`,
);
await prisma.$executeRawUnsafe(
'CREATE TRIGGER fail_publication_audit BEFORE INSERT ON public.admin_audit_logs FOR EACH ROW EXECUTE FUNCTION fail_publication_audit()',
);
try {
assert.equal(
(
await call(
'admin/worksheets/pub-rollback/status',
'PATCH',
{ status: 'active' },
admin,
)
).status,
503,
);
const row = await prisma.worksheet.findUniqueOrThrow({
where: { id: 'pub-rollback' },
});
assert.equal(row.status, 'draft');
assert.equal(row.firstPublishedAt, null);
assert.deepEqual(
await prisma.pageConfig.findMany(),
snapshots,
);
assert.equal(
await prisma.adminAuditLog.count({
where: { resourceId: 'pub-rollback' },
}),
0,
);
} finally {
await prisma.$executeRawUnsafe(
'DROP TRIGGER fail_publication_audit ON public.admin_audit_logs',
);
await prisma.$executeRawUnsafe(
'DROP FUNCTION fail_publication_audit()',
);
}
const service = app.get(PublicationService);
await prisma.user.update({
where: { id: actor.userId },
data: { role: 'user' },
});
await assert.rejects(
service.update(
'worksheet',
'pub-rollback',
{ status: 'active' },
actor,
'stale',
),
{ status: 403 },
);
assert.equal(
(
await call(
'admin/worksheets/pub-live',
'GET',
undefined,
admin,
)
).status,
403,
);
await prisma.user.update({
where: { id: actor.userId },
data: { role: 'admin', status: 'disabled' },
});
await assert.rejects(
service.update(
'worksheet',
'pub-rollback',
{ status: 'active' },
actor,
'stale',
),
{ status: 401 },
);
await prisma.user.update({
where: { id: actor.userId },
data: { status: 'active', authVersion: 2 },
});
assert.equal(
(
await call(
'admin/worksheet-resources',
'GET',
undefined,
admin,
)
).status,
401,
);
await assert.rejects(
service.update(
'resource',
'pub-resource',
{ status: 'hidden' },
actor,
'stale',
),
{ status: 401 },
);
},
);
},
);
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { LoginRateLimiter } from '../src/auth/login-rate-limiter';
import { ApiException } from '../src/common/api-exception';
test('登录分别限制 IP 与可信身份,窗口过期后恢复且返回重试期限', (context) => {
context.mock.method(Date, 'now', () => 1000);
const limiter = new LoginRateLimiter();
for (let i = 0; i < 30; i++) limiter.checkIp('127.0.0.1');
assert.throws(
() => limiter.checkIp('127.0.0.1'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'RATE_LIMITED' &&
error.retryAfterSeconds === 60,
);
limiter.checkIp('127.0.0.2');
for (let i = 0; i < 10; i++) limiter.checkIdentity('openid-a');
assert.throws(() => limiter.checkIdentity('openid-a'), ApiException);
limiter.checkIdentity('openid-b');
context.mock.method(Date, 'now', () => 61_000);
limiter.checkIp('127.0.0.1');
limiter.checkIdentity('openid-a');
});
test('大量来源不会无限增长内存,过期后可以回收容量', (context) => {
context.mock.method(Date, 'now', () => 0);
const limiter = new LoginRateLimiter();
for (let i = 0; i < 10_000; i++) limiter.checkIp(`ip-${i}`);
assert.throws(() => limiter.checkIp('new-ip'), ApiException);
limiter.checkIp('ip-0');
context.mock.method(Date, 'now', () => 60_000);
limiter.checkIp('new-ip');
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { getRuntimeOptions } from '../src/common/runtime-options';
test('使用本地默认 host 和 port', () => {
assert.deepEqual(getRuntimeOptions({}), {
host: '127.0.0.1',
port: 3000,
port: 8800,
});
});
+110 -1
View File
@@ -2,7 +2,13 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getServerConfig } from '../src/config/server-config';
const env = { JWT_SECRET: 'test-configuration-secret-12345678901234567890' };
const env = {
JWT_SECRET: 'test-configuration-secret-12345678901234567890',
DATABASE_URL:
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
};
test('启动配置必须提供有效 JWT 密钥,不使用共享默认密钥', () => {
for (const secret of [
@@ -33,6 +39,82 @@ test('Swagger 开发默认开启,生产默认关闭,支持显式开关', ()
);
});
test('真实登录接入后微信配置必须完整,缺省或留空都拒绝启动', () => {
for (const entry of [
{ WECHAT_MINI_APP_ID: undefined, WECHAT_MINI_APP_SECRET: undefined },
{ WECHAT_MINI_APP_ID: '', WECHAT_MINI_APP_SECRET: '' },
{ WECHAT_MINI_APP_ID: ' ', WECHAT_MINI_APP_SECRET: '\t' },
]) {
assert.throws(
() => getServerConfig({ ...env, ...entry }),
/WECHAT_MINI_APP/,
);
}
});
test('成对读取微信配置并去除首尾空白', () => {
const config = getServerConfig({
...env,
WECHAT_MINI_APP_ID: ' wx0123456789abcdef ',
WECHAT_MINI_APP_SECRET: ' test-only-wechat-secret ',
});
assert.equal(config.wechatMiniAppId, 'wx0123456789abcdef');
assert.equal(config.wechatMiniAppSecret, 'test-only-wechat-secret');
});
test('拒绝不完整或含内部空白的微信配置,错误不回显配置值', () => {
for (const entry of [
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: undefined,
},
{
WECHAT_MINI_APP_ID: undefined,
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
},
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: ' ',
},
{
WECHAT_MINI_APP_ID: 'wx0123 456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
},
{
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only\nwechat-secret',
},
]) {
assert.throws(
() => getServerConfig({ ...env, ...entry }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /WECHAT_MINI_APP/);
for (const value of Object.values(entry)) {
if (value?.trim())
assert.ok(!error.message.includes(value));
}
return true;
},
);
}
});
test('数据库连接必须为 PostgreSQL public schema,校验不回显含密码的 URL', () => {
for (const databaseUrl of [
'',
'mysql://user:private-password@localhost/db',
'postgresql:///db',
'postgresql://localhost',
'postgresql://localhost/db?schema=other',
])
assert.throws(
() => getServerConfig({ ...env, DATABASE_URL: databaseUrl }),
/DATABASE_URL/,
);
assert.equal(getServerConfig(env).databaseUrl, env.DATABASE_URL);
});
test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域配置', () => {
for (const entry of [
{ API_BODY_LIMIT_BYTES: '0' },
@@ -55,3 +137,30 @@ test('拒绝非法 body、JWT 有效期、日志级别、布尔开关和跨域
['https://example.com', 'http://localhost:3001'],
);
});
test('题型新品窗口与公共资源域名校验,缺省域名不构造七牛地址', () => {
assert.equal(getServerConfig(env).worksheetNewWindowDays, 30);
assert.equal(getServerConfig(env).qiniuPublicDomain, null);
assert.equal(
getServerConfig({
...env,
QINIU_PUBLIC_DOMAIN: 'https://assets.example.test/',
}).qiniuPublicDomain,
'https://assets.example.test',
);
for (const value of ['0', '366', '1e2', ''])
assert.throws(() =>
getServerConfig({ ...env, WORKSHEET_NEW_WINDOW_DAYS: value }),
);
for (const value of [
'http://assets.example.test',
'https://user:secret@example.test',
'https://example.test/path',
'https://example.test?sign=private',
'https://example.test#hash',
])
assert.throws(
() => getServerConfig({ ...env, QINIU_PUBLIC_DOMAIN: value }),
/QINIU_PUBLIC_DOMAIN/,
);
});
+4
View File
@@ -4,4 +4,8 @@ import { getServerConfig } from '../src/config/server-config';
export const testConfig = getServerConfig({
NODE_ENV: 'test',
JWT_SECRET: 'test-only-secret-123456789012345678901234567890',
DATABASE_URL:
'postgresql://test:test@127.0.0.1:1/doodle_test?schema=public',
WECHAT_MINI_APP_ID: 'wx0123456789abcdef',
WECHAT_MINI_APP_SECRET: 'test-only-wechat-secret',
});
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { WechatClient, type WechatFetch } from '../src/auth/wechat-client';
import { ApiException } from '../src/common/api-exception';
import { testConfig } from './test-config';
const responding =
(payload: unknown, status = 200): WechatFetch =>
async () =>
new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
test('微信兑换只向固定地址发送服务端凭据,忽略 session_key,不重试一次性 code', async () => {
let calls = 0;
const http: WechatFetch = async (input, init) => {
calls += 1;
const url = new URL(String(input));
assert.equal(url.origin, 'https://api.weixin.qq.com');
assert.equal(url.pathname, '/sns/jscode2session');
assert.equal(url.searchParams.get('appid'), testConfig.wechatMiniAppId);
assert.equal(
url.searchParams.get('secret'),
testConfig.wechatMiniAppSecret,
);
assert.equal(url.searchParams.get('js_code'), 'once-code');
assert.equal(init?.redirect, 'error');
assert.ok(init?.signal);
return responding({
openid: 'OpenIdA',
unionid: 'UnionA',
session_key: 'private-session-key',
})(input, init);
};
assert.deepEqual(
await new WechatClient(testConfig, http).exchangeCode('once-code'),
{ openid: 'OpenIdA', unionid: 'UnionA' },
);
assert.equal(calls, 1);
for (const unionid of [undefined, null, '']) {
const result = await new WechatClient(
testConfig,
responding({ openid: 'A', unionid, errcode: 0 }),
).exchangeCode('code');
assert.equal(result.unionid, null);
}
});
test('微信错误、畸形响应和网络失败映射为有限安全错误,不传播原始消息', async () => {
for (const [payload, status, reason] of [
[{ errcode: 40029 }, 400, 'WECHAT_CODE_INVALID'],
[{ errcode: 40163 }, 400, 'WECHAT_CODE_INVALID'],
[{ errcode: 40226 }, 403, 'WECHAT_LOGIN_BLOCKED'],
[{ errcode: 45011 }, 429, 'RATE_LIMITED'],
[{ errcode: -1 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[
{ errcode: 40013, errmsg: 'private-secret' },
503,
'AUTH_UPSTREAM_UNAVAILABLE',
],
[{ openid: '' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: 'A', unionid: 123 }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: 'x'.repeat(129) }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[{ openid: ' A' }, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[null, 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
[[], 503, 'AUTH_UPSTREAM_UNAVAILABLE'],
] as const) {
await assert.rejects(
new WechatClient(testConfig, responding(payload)).exchangeCode(
'code',
),
(error: unknown) => {
assert.ok(error instanceof ApiException);
assert.equal(error.getStatus(), status);
assert.equal(error.reason, reason);
assert.ok(
!JSON.stringify(error.getResponse()).includes(
'private-secret',
),
);
return true;
},
);
}
let calls = 0;
const failed: WechatFetch = async () => {
calls += 1;
throw new Error('https://api.weixin.qq.com?secret=private-secret');
};
await assert.rejects(
new WechatClient(testConfig, failed).exchangeCode('code'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
assert.equal(calls, 1);
for (const http of [
responding({}, 502),
(async () => new Response('not-json')) as WechatFetch,
])
await assert.rejects(
new WechatClient(testConfig, http).exchangeCode('code'),
(error: unknown) =>
error instanceof ApiException &&
error.reason === 'AUTH_UPSTREAM_UNAVAILABLE',
);
});
+523
View File
@@ -0,0 +1,523 @@
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),
);
});
},
);
@@ -0,0 +1,194 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { test } from 'node:test';
import { PrismaService } from '../src/database/prisma.service';
import { WorksheetImporter } from '../src/imports/worksheet-import';
import { testConfig } from './test-config';
import { worksheetFixture as fixture } from './worksheet-import.fixture';
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 db = new PrismaService({
...testConfig,
databaseUrl: databaseUrl!,
});
context.after(() => db.onModuleDestroy());
const prisma = db.client;
const importer = new WorksheetImporter(db);
await prisma.category.create({
data: {
status: 'active',
id: fixture.category,
name: '导入验证',
icon: '📐',
color: '#fff',
},
});
const run = (rows: unknown[], dryRun = false, metadataOnly = true) => {
const source = rows.map((row) => JSON.stringify(row)).join('\n');
return importer.importExport(source, {
dryRun,
metadataOnly,
requestId: randomUUID(),
sourceSha256: createHash('sha256').update(source).digest('hex'),
});
};
const counts = async () => ({
assets: await prisma.asset.count(),
worksheets: await prisma.worksheet.count(),
audits: await prisma.adminAuditLog.count(),
});
await context.test(
'默认拒绝未就绪预览;预演及无效分类不写任何数据',
async () => {
const before = await counts();
await assert.rejects(run([fixture], false, false), {
status: 409,
});
await assert.rejects(
run([{ ...fixture, category: 'missing-category' }]),
{ status: 400 },
);
const preview = await run([fixture], true);
assert.equal(preview.inserted, 1);
assert.equal(preview.pendingPreviews, 1);
assert.equal(preview.readyForPublicUse, false);
assert.deepEqual(await counts(), before);
},
);
await context.test(
'同批保存原 ID、原始计数、时间、旧年级及 pending 资产,不伪造资源或年级关系',
async () => {
const report = await run([{ ...fixture, grade: 23 }]);
assert.equal(report.inserted, 1);
assert.equal(report.assetsInserted, 1);
assert.equal(report.gradeRelationsInserted, 0);
assert.equal(report.warnings[0]?.originalValue, 23);
const row = await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
});
assert.equal(row.grade, 23);
assert.equal(row.downloads, 23n);
assert.equal(row.downloadsSeed, 12n);
assert.equal(
row.createdAt.toISOString(),
fixture.createdAt.$date,
);
assert.equal(
row.updatedAt.toISOString(),
fixture.updatedAt.$date,
);
assert.equal(row.firstPublishedAt, null);
assert.equal(row.contentUpdatedAt, null);
assert.equal(row.status, 'active');
const asset = await prisma.asset.findUniqueOrThrow({
where: { id: row.previewAssetId },
});
assert.equal(asset.sourceRef, fixture.previewImg);
assert.equal(
asset.sourceRefHash,
createHash('sha256')
.update(fixture.previewImg)
.digest('hex'),
);
assert.equal(asset.status, 'pending');
assert.equal(asset.objectKey, null);
assert.equal(asset.bucket, null);
assert.equal(asset.mimeType, null);
assert.equal(
await prisma.worksheetGrade.count({
where: { worksheetId: fixture._id },
}),
0,
);
assert.equal(
await prisma.worksheetResource.count({
where: { worksheetId: fixture._id },
}),
0,
);
const audit = await prisma.adminAuditLog.findFirstOrThrow({
where: {
resourceId: fixture._id,
action: 'worksheet.import',
},
});
assert.equal(audit.actorType, 'migration');
assert.equal(
(audit.afterData as { downloads: string }).downloads,
'23',
);
},
);
await context.test(
'重导不新增或更新时间,共享预览复用资产;不同旧快照禁止覆盖',
async () => {
const before = await counts();
const stored = await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
});
const repeated = await run([{ ...fixture, grade: 23 }]);
assert.equal(repeated.unchanged, 1);
assert.equal(repeated.assetsInserted, 0);
assert.deepEqual(await counts(), before);
assert.deepEqual(
await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
}),
stored,
);
const shared = await run([
{ ...fixture, _id: 'worksheet-import-shared' },
]);
assert.equal(shared.assetsInserted, 0);
assert.equal(shared.assetsReused, 1);
assert.equal(await prisma.asset.count(), before.assets);
await assert.rejects(
run([{ ...fixture, grade: 23, downloads: 24 }]),
{ status: 409 },
);
assert.equal(
(
await prisma.worksheet.findUniqueOrThrow({
where: { id: fixture._id },
})
).downloads,
23n,
);
},
);
await context.test(
'后半批冲突会回滚前面新增题型、资产及审计',
async () => {
const before = await counts();
await assert.rejects(
run([
{
...fixture,
_id: 'worksheet-import-rollback',
previewImg:
'cloud://fixture-env/assets/previews/rollback.jpg',
},
{ ...fixture, grade: 23, title: '禁止覆盖' },
]),
{ status: 409 },
);
assert.deepEqual(await counts(), before);
assert.equal(
await prisma.worksheet.count({
where: { id: 'worksheet-import-rollback' },
}),
0,
);
},
);
},
);
@@ -0,0 +1,25 @@
/** 最小旧格式样本,不依赖附件路径或线上文件。 */
export const worksheetFixture = {
_id: 'worksheet-import-one',
title: '导入测试',
subtitle: '',
category: 'worksheet-import-test',
subcategory: '',
path: '/mathPages/mathDraw/mathDraw?id=worksheet-import-one',
ageMin: 3,
ageMax: 6,
grade: 0,
difficulty: 1,
previewImg: 'cloud://fixture-env/assets/previews/one.jpg',
tags: ['数学'],
isNew: false,
isHot: true,
sortOrder: 5,
downloads: 23,
likes: 2,
status: 'active',
createdAt: { $date: '2026-04-24T05:26:16.507Z' },
updatedAt: { $date: '2026-09-07T05:43:03.511Z' },
downloads_seed: 12,
likes_seed: 14,
};
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseWorksheetExport } from '../src/imports/worksheet-import';
import { worksheetFixture as fixture } from './worksheet-import.fixture';
test('题型导入:JSON Lines、BSON 日期、计数和缺省字段准确保留', () => {
const row = {
...fixture,
grade: 23,
downloads_seed: undefined,
contentUpdatedAt: { $date: '2026-06-01T08:00:00+08:00' },
};
const result = parseWorksheetExport(`\uFEFF${JSON.stringify(row)}\n`);
const stored = result.rows[0]!.data;
assert.equal(stored.downloads, 23n);
assert.equal(stored.downloadsSeed, 0n);
assert.equal(stored.likesSeed, 14n);
assert.equal(stored.grade, 23);
assert.equal(stored.subcategory, null);
assert.equal(stored.createdAt.toISOString(), fixture.createdAt.$date);
assert.equal(
stored.contentUpdatedAt?.toISOString(),
'2026-06-01T00:00:00.000Z',
);
assert.equal(stored.firstPublishedAt, null);
assert.equal(stored.rendererKey, null);
assert.equal(result.warnings.length, 2);
assert.equal(
parseWorksheetExport(JSON.stringify([fixture])).rows[0]?.data.path,
fixture.path,
);
});
test('题型导入:拒绝未知字段、重复 ID、非法日期、类型及计数精度丢失', () => {
for (const patch of [
{ unknown: true },
{ title: 42 },
{ category: null },
{ difficulty: 0 },
{ ageMax: 1 },
{ tags: ['a', 1] },
{ isNew: 'false' },
{ downloads: -1 },
{ downloads: Number.MAX_SAFE_INTEGER + 1 },
{ grade: 32768 },
{ status: 'deleted' },
{ previewImg: 'https://example.com/a.jpg' },
{ createdAt: { $date: '2026-02-30T00:00:00Z' } },
{ createdAt: { $date: '2026-01-01' } },
{ createdAt: null },
{ createdAt: { $date: fixture.createdAt.$date, injected: true } },
{ updatedAt: { $date: '2025-01-01T00:00:00Z' } },
])
assert.throws(() =>
parseWorksheetExport(JSON.stringify([{ ...fixture, ...patch }])),
);
assert.throws(() =>
parseWorksheetExport(JSON.stringify([fixture, fixture])),
);
for (const value of ['', '[]', '{}', '{broken'])
assert.throws(() => parseWorksheetExport(value));
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
parseWorksheetQuery,
WorksheetQueryDto,
} from '../src/worksheets/worksheet-query.dto';
test('题型查询参数:零年级、多个年级去重、false 与组合筛选可准确解析', () => {
const query = parseWorksheetQuery({
grade: '0',
ageBand: '3-4',
tag: ' 数学 ',
isHot: 'false',
});
assert.deepEqual(query.grades, [0]);
assert.equal(query.isHot, false);
assert.equal(query.page, 1);
assert.equal(query.pageSize, 20);
assert.equal(query.tag, '数学');
assert.deepEqual(
parseWorksheetQuery({ grades: '-4,0,6,0' }).grades,
[-4, 0, 6],
);
assert.equal(parseWorksheetQuery({ age: '0' }).age, 0);
});
test('题型查询参数:非法值、重复参数和越权状态字段不能被静默忽略', () => {
const bad: unknown[] = [
{ page: '0' },
{ page: '1e2' },
{ pageSize: '101' },
{ page: ['1', '2'] },
{ grade: '23' },
{ grade: '0', grades: '1' },
{ grades: '0,,1' },
{ grades: '' },
{ ageBand: '3-5' },
{ age: '4', ageBand: '3-4' },
{ isHot: '0' },
{ isNew: 'False' },
{ sort: 'random' },
{ tag: ['a', 'b'] },
{ tag: '' },
{ status: 'draft' },
{ rawStats: 'true' },
];
for (const query of bad)
assert.throws(() => parseWorksheetQuery(query as WorksheetQueryDto));
});