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