test: cover admin gallery prompt moderation

This commit is contained in:
FengLee
2026-05-20 10:37:00 +08:00
parent 0ceabafb6d
commit 8595cdc6a4
3 changed files with 292 additions and 0 deletions

View File

@@ -14,6 +14,7 @@
"preinstall": "npx only-allow pnpm",
"lint": "eslint",
"start": "bash ./scripts/start.sh",
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
"pm2:save": "pm2 save",
"migration:check": "node ./scripts/migration-integrity-check.mjs",

View File

@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
function createWork(overrides = {}) {
return {
id: '11111111-1111-1111-1111-111111111111',
user_id: '22222222-2222-2222-2222-222222222222',
type: 'text2img',
title: 'public work',
prompt: 'old public prompt',
negative_prompt: null,
result_url: '/api/local-storage/gallery/image.webp',
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
likes_count: 3,
is_public: true,
status: 'completed',
created_at: '2026-05-20T00:00:00.000Z',
author_email: 'author@example.com',
author_nickname: 'Author',
author_display_nickname: 'Author Display',
author_avatar_url: null,
...overrides,
};
}
function createServiceHarness({ work, emailFails = false } = {}) {
const state = {
work: work || createWork(),
updates: [],
emails: [],
logs: [],
};
return {
state,
deps: {
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
updatePrompt: async (workId, prompt) => {
state.updates.push({ workId, prompt });
state.work = { ...state.work, prompt };
return state.work;
},
sendEmail: async (message) => {
state.emails.push(message);
if (emailFails) throw new Error('SMTP down');
},
writeLog: async (entry) => {
state.logs.push(entry);
},
},
};
}
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
const baseInput = {
workId: '11111111-1111-1111-1111-111111111111',
prompt: 'new compliant prompt',
emailSubject: '公开作品提示词已调整',
emailBody: '你的公开作品提示词已根据平台规范调整。',
reasonKey: 'remove_sensitive_words',
};
await runTest('rejects non-public works', async () => {
const { deps, state } = createServiceHarness({ work: createWork({ is_public: false }) });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects missing author email', async () => {
const { deps, state } = createServiceHarness({ work: createWork({ author_email: '' }) });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('rejects unchanged prompt', async () => {
const { deps, state } = createServiceHarness();
await assert.rejects(
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
/提示词没有变化/,
);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 0);
});
await runTest('does not update prompt when email sending fails', async () => {
const { deps, state } = createServiceHarness({ emailFails: true });
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
assert.equal(state.updates.length, 0);
assert.equal(state.emails.length, 1);
});
await runTest('sends email before updating prompt', async () => {
const { deps, state } = createServiceHarness();
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.emails.length, 1);
assert.equal(state.updates.length, 1);
assert.equal(state.updates[0].prompt, 'new compliant prompt');
assert.equal(result.work.prompt, 'new compliant prompt');
});
await runTest('writes moderation log metadata without full prompt text', async () => {
const { deps, state } = createServiceHarness();
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
assert.equal(state.logs.length, 1);
const logText = JSON.stringify(state.logs[0]);
assert.match(logText, /remove_sensitive_words/);
assert.doesNotMatch(logText, /old public prompt/);
assert.doesNotMatch(logText, /new compliant prompt/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -0,0 +1,167 @@
export type AdminGalleryPromptReasonKey =
| 'remove_sensitive_words'
| 'improve_wording'
| 'remove_private_info'
| 'platform_policy_adjustment'
| 'custom';
export interface AdminGalleryPromptAdmin {
userId: string;
role: string;
}
export interface AdminGalleryPromptWorkRow {
id: string;
user_id: string | null;
type: string | null;
title: string | null;
prompt: string | null;
negative_prompt?: string | null;
result_url: string | null;
thumbnail_url?: string | null;
likes_count?: number | null;
is_public: boolean | null;
status: string | null;
created_at: string | Date | null;
author_email: string | null;
author_nickname?: string | null;
author_display_nickname?: string | null;
author_avatar_url?: string | null;
}
export interface AdminGalleryPromptInput {
workId: string;
prompt: string;
emailSubject: string;
emailBody: string;
reasonKey?: string;
}
export interface AdminGalleryPromptEmailMessage {
to: string;
subject: string;
body: string;
work: AdminGalleryPromptWorkRow;
reasonKey: AdminGalleryPromptReasonKey;
}
export interface AdminGalleryPromptDeps {
admin: AdminGalleryPromptAdmin;
loadWork: (workId: string) => Promise<AdminGalleryPromptWorkRow | null>;
updatePrompt: (workId: string, prompt: string) => Promise<AdminGalleryPromptWorkRow>;
sendEmail: (message: AdminGalleryPromptEmailMessage) => Promise<void>;
writeLog: (entry: Record<string, unknown>) => Promise<void>;
}
export class AdminGalleryPromptError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
export async function updateAdminGalleryPrompt(input: AdminGalleryPromptInput, deps: AdminGalleryPromptDeps) {
const workId = normalizeUuid(input.workId, '缺少作品 ID');
const prompt = normalizeRequiredText(input.prompt, '请填写新的提示词', 8000);
const emailSubject = normalizeRequiredText(input.emailSubject, '请填写邮件标题', 120);
const emailBody = normalizeRequiredText(input.emailBody, '请填写邮件正文', 5000);
const reasonKey = normalizeReasonKey(input.reasonKey);
const work = await deps.loadWork(workId);
if (!work || work.is_public !== true || work.status !== 'completed' || !work.result_url) {
throw new AdminGalleryPromptError('作品不存在或不是公开作品', 404);
}
const oldPrompt = String(work.prompt || '').trim();
if (oldPrompt === prompt) {
throw new AdminGalleryPromptError('提示词没有变化', 400);
}
const authorEmail = normalizeEmailAddress(work.author_email);
if (!isValidEmailAddress(authorEmail)) {
throw new AdminGalleryPromptError('作者邮箱不可用,无法完成邮件通知', 400);
}
await deps.sendEmail({ to: authorEmail, subject: emailSubject, body: emailBody, work, reasonKey });
const updated = await deps.updatePrompt(workId, prompt);
await deps.writeLog({
type: 'admin',
level: 'info',
action: 'admin_gallery_prompt_update',
message: '管理员修改公开画廊作品提示词并发送邮件通知',
userId: deps.admin.userId,
targetType: 'work',
targetId: workId,
metadata: {
workId,
authorId: work.user_id,
authorEmail,
reasonKey,
oldPromptLength: oldPrompt.length,
newPromptLength: prompt.length,
notificationSent: true,
},
});
return {
work: toAdminGalleryPromptWork(updated, authorEmail),
notificationSent: true,
};
}
export function toAdminGalleryPromptWork(row: AdminGalleryPromptWorkRow, authorEmail = normalizeEmailAddress(row.author_email)) {
return {
id: row.id,
type: row.type,
title: row.title,
prompt: row.prompt,
negativePrompt: row.negative_prompt || null,
url: row.result_url,
thumbnailUrl: row.thumbnail_url || null,
likes: Number(row.likes_count || 0),
authorId: row.user_id,
authorEmail,
authorNickname: row.author_display_nickname || row.author_nickname || (authorEmail ? authorEmail.split('@')[0] : '匿名用户'),
authorAvatarUrl: row.author_avatar_url || null,
publishedAt: row.created_at,
};
}
export function normalizeReasonKey(value: unknown): AdminGalleryPromptReasonKey {
if (
value === 'remove_sensitive_words'
|| value === 'improve_wording'
|| value === 'remove_private_info'
|| value === 'platform_policy_adjustment'
) {
return value;
}
return 'custom';
}
function normalizeUuid(value: unknown, message: string) {
const text = typeof value === 'string' ? value.trim() : '';
if (!/^[0-9a-fA-F-]{36}$/.test(text)) {
throw new AdminGalleryPromptError(message, 400);
}
return text;
}
function normalizeRequiredText(value: unknown, message: string, maxLength: number) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) {
throw new AdminGalleryPromptError(message, 400);
}
return text.slice(0, maxLength);
}
function normalizeEmailAddress(value: unknown) {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
function isValidEmailAddress(value: string) {
return value.length > 3 && value.length <= 254 && /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(value);
}