Files
doodle-store/apps/server/test/content-publication-database.test.ts

726 lines
28 KiB
TypeScript

import 'reflect-metadata';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type {
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 },
);
},
);
},
);