Files
wordpress-theme-aksrt/tests/widget-system/creator-studio-contract.test.cjs
T
2026-11-07 00:58:13 +08:00

320 lines
17 KiB
JavaScript

'use strict';
/**
* Creator Studio 契约测试。
*
* 这些断言锁定“服务端才是权威”的边界:状态、付费、审核与私有附件不能由前端
* 直接写穿;已发布文章的改稿不得覆盖线上内容;私有附件不得暴露公开路径。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const exists = (file) => fs.existsSync(path.join(root, file));
const creatorRest = read('src/Core/CreatorRest.php');
const versions = read('src/Core/SubmissionVersion.php');
const privateAssets = read('src/Content/PrivateAssets.php');
const protectedDownload = read('src/Content/ProtectedDownload.php');
const studio = read('src/Core/CreatorStudio.php');
const pageSubmit = read('templates/pages/page-submit.php');
const saveTrait = read('src/Core/FrontSubmitSave.php');
const domainModel = read('src/Core/DomainModel.php');
const appSource = read('assets/creator-studio/src/index.tsx');
const editorToolbar = read('assets/creator-studio/src/components/editor-toolbar.tsx');
const drawer = read('assets/creator-studio/src/panels/publish-drawer.tsx');
const api = read('assets/creator-studio/src/services/api.ts');
test('creator REST routes always register a permission callback', () => {
const routes = creatorRest.match(/register_rest_route\(/g) || [];
const permissions = creatorRest.match(/'permission_callback'\s*=>/g) || [];
assert.ok(routes.length >= 5, 'creator API should expose bootstrap/save/submit/preview/upload routes');
assert.equal(routes.length, permissions.length, 'every creator route needs a permission callback');
assert.doesNotMatch(creatorRest, /'permission_callback'\s*=>\s*'__return_true'/);
});
test('studio writes reuse the existing submit service instead of raw post APIs', () => {
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_draft'\)/);
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_save'\)/);
assert.doesNotMatch(creatorRest, /wp_insert_post\(/);
assert.doesNotMatch(creatorRest, /wp_update_post\(\$update/);
assert.match(creatorRest, /can_use_studio/);
assert.match(creatorRest, /user_can_cap\('new_post_audit_no', \$user_id\)/);
});
test('published edits never overwrite the live post before approval', () => {
assert.match(versions, /public static function requires_version/);
assert.match(versions, /post_status === 'publish'/);
assert.match(versions, /self::POST_TYPE/);
assert.match(versions, /public function approve/);
assert.match(versions, /public function reject/);
assert.match(versions, /jio_version_source_changed/);
assert.match(versions, /'status'\s*=>\s*409/);
// Approval is the only path that writes title/content back to the source post.
assert.match(versions, /'ID'\s*=>\s*\$source_id[\s\S]{0,400}?wp_update_post/);
assert.match(creatorRest, /SubmissionVersion::requires_version\(\$post_id\)[\s\S]{0,400}?save_working/);
});
test('stale autosave requests are rejected instead of overwriting', () => {
assert.match(versions, /'jio_version_conflict'/);
assert.match(versions, /current_rev/);
assert.match(versions, /base_rev/);
assert.match(appSource, /status === 409/);
});
test('drafts save with partial content instead of being rejected', () => {
// 只写标题、或正文只有图片/表格/代码块时,也必须能存草稿。
assert.match(saveTrait, /请先写点什么再保存/);
const draftBranch = saveTrait.indexOf('if (!$is_draft) {');
const titleGuard = saveTrait.indexOf("if ($title === '') {");
assert.ok(draftBranch !== -1 && titleGuard !== -1, 'draft branch and title guard both exist');
assert.ok(draftBranch < titleGuard, 'title/content requirements only apply to submit');
// 前端空文档不再发保存请求。
assert.match(appSource, /const hasDocument = /);
assert.match(appSource, /if \(!hasDocument\) \{\s*return true;/);
assert.match(appSource, /img\|table\|pre\|hr\|iframe\|video/);
});
test('paid payload is validated before any post write', () => {
const prepareAt = saveTrait.indexOf('prepare_pay_meta');
const insertAt = saveTrait.indexOf('wp_insert_post');
const updateAt = saveTrait.indexOf('wp_update_post');
assert.notEqual(prepareAt, -1, 'persist must validate pay payload');
assert.ok(prepareAt < insertAt, 'pay validation must precede insert');
assert.ok(prepareAt < updateAt, 'pay validation must precede update');
assert.match(saveTrait, /if \(empty\(\$pay_result\['success'\]\)\) \{\s*return \$pay_result;/);
});
test('private attachments never expose a public URL', () => {
assert.match(privateAssets, /define\('JIO_PRIVATE_STORAGE_DIR'/);
assert.match(privateAssets, /is_available\(\)/);
assert.match(privateAssets, /is_verified/);
assert.match(privateAssets, /return \$outside \|\| \$explicit;/);
assert.doesNotMatch(privateAssets, /wp_upload_dir\(/);
assert.doesNotMatch(privateAssets, /wp_get_attachment_url/);
assert.match(privateAssets, /asset_key/);
assert.match(privateAssets, /@chmod\(\$target, 0600\)/);
});
test('protected downloads require entitlement, tokens, and the private asset id', () => {
assert.match(protectedDownload, /jio_can_download/);
assert.match(protectedDownload, /Visibility::can_download_resource/);
assert.match(protectedDownload, /asset_id/);
assert.match(protectedDownload, /jio_token_expired/);
assert.match(protectedDownload, /jio_token_exhausted/);
assert.match(protectedDownload, /UPDATE \{\$table\} SET used_count = used_count \+ 1/);
assert.match(protectedDownload, /GREATEST\(used_count - 1, 0\)/);
assert.match(protectedDownload, /X-Content-Type-Options/);
assert.doesNotMatch(protectedDownload, /wp_get_attachment_url/);
});
test('model stores protected downloads by asset key only', () => {
assert.match(domainModel, /'protected'/);
assert.match(domainModel, /'asset_key'/);
assert.match(domainModel, /PrivateAssets::sanitize_asset_key/);
assert.match(domainModel, /\$is_protected\) \{\s*\$type = 'protected';\s*\$url = '';/);
});
test('studio is mounted only on the submit template with a fallback', () => {
assert.match(pageSubmit, /CreatorStudio::get_instance\(\)/);
assert.match(pageSubmit, /is_built\(\)/);
assert.match(pageSubmit, /get_current_user_id\(\) > 0/);
assert.match(pageSubmit, /id="jio-creator-studio"/);
// Guests and missing builds fall back to the existing TinyMCE form.
assert.match(pageSubmit, /wp_enqueue_editor\(\);/);
assert.match(studio, /is_page_template\('templates\/pages\/page-submit.php'\)/);
assert.match(studio, /index\.asset\.php/);
assert.match(studio, /wp_add_inline_script/);
});
test('studio app uses TinyMCE while preserving autosave, recovery, and publishing', () => {
assert.match(appSource, /useAutosave/);
assert.match(appSource, /useLocalRecovery/);
assert.match(appSource, /PublishDrawer/);
assert.match(appSource, /wpEditor\.initialize\(EDITOR_ID/);
assert.match(appSource, /editor\.getContent\(\)/);
assert.match(appSource, /editorRef\.current\?\.setContent/);
assert.doesNotMatch(appSource, /BlockEditorProvider|registerCoreBlocks|BlockList|registerCoreBlocks/);
assert.doesNotMatch(creatorRest, /block_content\(/);
assert.match(creatorRest, /'post_content'\s*=>\s*\$payload\['content'\]/);
assert.match(api, /'X-WP-Nonce'/);
assert.match(drawer, /canPay/);
assert.match(drawer, /canPayDownload/);
assert.match(drawer, /私有附件存储尚未配置/);
assert.match(drawer, /onSubmit\(false\)/);
assert.match(drawer, /立即发布/);
});
test('studio enqueues the WordPress editor and TinyMCE media tools', () => {
const enqueue = read('src/Core/CreatorStudio.php');
assert.match(enqueue, /wp_enqueue_editor\(\)/);
assert.match(enqueue, /\$deps\[\]\s*=\s*'editor'/);
assert.match(enqueue, /jio-creator-tinymce-tools/);
assert.match(enqueue, /tinymce_payload/);
assert.doesNotMatch(enqueue, /wp-format-library|gutenberg-blocks\.js/);
});
test('fixed top toolbar has TinyMCE formatting, media, and extension actions', () => {
assert.match(appSource, /className="jio-creator__bar"/);
assert.match(appSource, /undoManager\.undo\(\)/);
assert.match(appSource, /undoManager\.redo\(\)/);
assert.match(appSource, /editor\.formatter\.toggle\(value \|\| 'h2'\)/);
assert.match(editorToolbar, /value=\{formatValue === 'blockquote' \? 'p' : formatValue\}/);
assert.match(editorToolbar, /canUndo/);
assert.match(editorToolbar, /canRedo/);
assert.match(editorToolbar, /onCommand\('Bold'\)/);
assert.match(editorToolbar, /onCommand\('Italic'\)/);
assert.match(editorToolbar, /onOpenMedia\('image'\)/);
assert.match(editorToolbar, /onOpenMedia\('video'\)/);
assert.match(editorToolbar, /onClick=\{onOpenLink\}/);
// 引用不再是标题下拉的一部分,而是独立按钮(带样式菜单)。
assert.match(editorToolbar, /onInsertQuote/);
assert.doesNotMatch(editorToolbar, /<option value="blockquote"/);
assert.doesNotMatch(editorToolbar, /onCommand\('format', 'blockquote'\)/);
for (const kind of ['list', 'toc', 'divider', 'code', 'footnote', 'math', 'table']) {
assert.ok(editorToolbar.includes(`onInsertBlock('${kind}')`), `missing block button: ${kind}`);
}
// 表格 / 公式 / 注释 / 代码都走参数弹窗,不再是固定内容。
assert.match(appSource, /<BlockDialog/);
assert.match(appSource, /setBlockDialog\(kind as BlockDialogKind\)/);
// 代码块直接插入,不弹窗。
assert.match(appSource, /insertHtml\(codeBlockMarkup\('generic'\)\)/);
assert.doesNotMatch(appSource, /setCodeDialog/);
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /kind === 'code'/);
assert.match(read('assets/creator-studio/src/components/block-dialog.tsx'), /onSubmit\(\{ kind, rows: clamp\(rows, 1, 20\), cols: clamp\(cols, 1, 10\), header, striped \}\)/);
assert.match(appSource, /onInsertBlock=\{insertBlock\}/);
assert.match(appSource, /onInsertQuote=\{insertQuote\}/);
const markup = read('assets/creator-studio/src/utils/block-markup.ts');
assert.match(markup, /jio-math/);
// 引用沿用主题的 quote_q 结构与配色类。
assert.match(markup, /quote_q/);
assert.match(markup, /qe_wzk_c-red/);
// 公式与注释用短代码落库,由服务端保证渲染。
assert.doesNotMatch(markup, /E = mc\^2/);
// 编辑器展示渲染结果,序列化时再换回短代码。
assert.match(markup, /data-jio-math-source/);
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_math display=/);
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_footnote\]/);
assert.match(markup, /\[jio_toc\]/);
// 代码块角标不再输出固定「高亮代码」文案。
assert.doesNotMatch(read('assets/css/frontend/pages/submit-editor.css'), /高亮代码/);
assert.doesNotMatch(read('assets/css/tinymce.css'), /content:\s*'高亮代码'/);
const blocks = read('src/Content/ContentBlocks.php');
const shortcodes = read('src/Core/Shortcodes.php');
assert.match(shortcodes, /add_shortcode\('jio_math'/);
assert.match(shortcodes, /add_shortcode\('jio_footnote'/);
assert.match(shortcodes, /add_shortcode\('jio_toc'/);
assert.match(blocks, /function shortcode_math/);
assert.match(blocks, /function shortcode_footnote/);
assert.match(blocks, /function append_footnotes/);
assert.match(blocks, /<sup>\$1\$2<\/sup>/);
// 删除键必须能移除相邻的块级元素。
assert.match(appSource, /function removeAdjacentBlock/);
assert.match(appSource, /removeAdjacentBlock\(editor, event\)/);
// 工具栏按钮统一为「图标在上、文字在下」。
assert.match(editorToolbar, /jio-creator__tool-label/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator__tool-btn\s*\{[\s\S]*?flex-direction:\s*column/);
// 保存 / 预览 / 发布固定在底部状态栏,顶栏只保留工具。
assert.match(appSource, /<StatusBar/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /保存草稿/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /预览/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown 语法输入中/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /<SaveState value=\{saveState\}/);
assert.match(appSource, /draftRef\.current\.postId/);
const barStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
assert.match(barStyles, /\.jio-creator__status\s*\{[\s\S]*?position:\s*fixed/);
assert.match(barStyles, /\.jio-creator__status\s*\{[\s\S]*?bottom:\s*0/);
assert.match(editorToolbar, /role="toolbar"/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator__bar\s*\{[^}]*position:\s*fixed/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator\s*\{[\s\S]*?padding-top:\s*var\(--jc-bar\)/);
});
test('creator studio build artifacts and contracts exist', () => {
assert.ok(exists('assets/creator-studio/package.json'));
assert.ok(exists('assets/creator-studio/package-lock.json'));
assert.ok(exists('assets/creator-studio/build/index.asset.php'));
assert.ok(exists('assets/creator-studio/build/index.js'));
assert.ok(exists('assets/creator-studio/build/index.css'));
assert.ok(exists('assets/js/protected-download.js'));
assert.ok(exists('src/Core/PreviewToken.php'));
assert.ok(exists('src/Admin/Native/SubmissionReview.php'));
});
test('沉浸式布局屏蔽主题头部/页脚与挂件区', () => {
const header = read('header.php');
const footer = read('footer.php');
// 工作台模式下这些输出必须可关闭,否则顶栏重复、正文被挂件挤压。
assert.match(header, /apply_filters\('jio_render_header', true\)/);
assert.match(header, /apply_filters\('jio_render_fluid_widgets', true\)/);
assert.match(footer, /apply_filters\('jio_render_footer', true\)/);
assert.match(pageSubmit, /add_filter\('jio_render_header', '__return_false'\)/);
assert.match(pageSubmit, /add_filter\('jio_render_footer', '__return_false'\)/);
assert.match(pageSubmit, /add_filter\('jio_render_fluid_widgets', '__return_false'\)/);
});
test('TinyMCE media buttons reuse Jio uploads and preserve bookmarks', () => {
assert.match(appSource, /JioSubmitMedia/);
assert.match(appSource, /editor\.selection\.getBookmark/);
assert.match(appSource, /moveToBookmark/);
assert.match(appSource, /data-edit-file-id/);
assert.match(appSource, /data-video-url/);
assert.match(read('src/Core/CreatorStudio.php'), /front-submit\/upload\/image/);
assert.match(read('assets/css/frontend/pages/submit.css'), /\.jio-editor-media\s*\{/);
assert.match(read('assets/creator-studio/src/components/publish-settings.tsx'), /添加封面/);
assert.match(appSource, /<PublishSettings/);
});
test('writing surface is a centered paper card with a publish settings card below', () => {
const styles = read('assets/creator-studio/src/styles/studio.scss');
const settings = read('assets/creator-studio/src/components/publish-settings.tsx');
assert.doesNotMatch(appSource, /jio-creator__excerpt/);
assert.match(appSource, /请输入标题(最多 %d 个字)/, 'title placeholder must state the character limit');
assert.match(appSource, /className="jio-creator__title"/);
assert.match(appSource, /<PublishSettings/);
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*background:\s*#fff/);
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*width:\s*min\(900px/);
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*margin:\s*40px auto 0/);
assert.match(styles, /\.jio-page-submit\s*\{[^}]*background:\s*#f6f6f6/);
assert.match(styles, /\.jio-creator__settings\s*\{[^}]*background:\s*#fff/);
assert.match(styles, /\.jio-creator__title\s*\{[\s\S]*?font-size:\s*clamp\(26px, 3vw, 32px\)/);
assert.match(styles, /\.jio-creator__title\s*\{[\s\S]*?border-bottom:\s*1px solid/);
assert.match(settings, /定时发布/);
assert.match(settings, /type="datetime-local"/);
assert.match(settings, /canSchedule/);
assert.match(appSource, /scheduleEnabled=\{!!draft\.schedule\.enabled\}/);
});
test('scheduled publishing is validated and applied server side', () => {
const save = read('src/Core/FrontSubmitSave.php');
const rest = read('src/Core/CreatorRest.php');
assert.match(save, /private function normalize_schedule\(array \$data\): string/);
assert.match(save, /\$status\s*=\s*'future';/);
assert.match(save, /\$postarr\['post_date'\]\s*=\s*\$schedule_at;/);
assert.match(save, /get_gmt_from_date\(\$schedule_at\)/);
assert.match(save, /case 'future':/);
assert.match(rest, /'schedule'\s*=>\s*\(array\) \$request->get_param\('schedule'\)/);
assert.match(rest, /'canSchedule'\s*=>\s*\$cap\('new_post_audit_no'\)/);
});
test('the writing card has no author strip above the title', () => {
assert.doesNotMatch(appSource, /AuthorRow/);
assert.doesNotMatch(appSource, /原创内容/);
assert.doesNotMatch(read('assets/creator-studio/src/styles/studio.scss'), /jio-creator__author/);
});
test('character count lives in the fixed bottom bar, not a reading-time footer', () => {
assert.doesNotMatch(appSource, /jio-creator__footer/);
assert.doesNotMatch(appSource, /分钟阅读/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
});