Files
wordpress-theme-aksrt/tests/widget-system/invite-admin-contract.test.cjs
T
kate522andClaude Fable 5 869d437485 test(admin): match invite bulk row by class token, not exact attribute
The bulk row gained the shared aksrt-native-bulk class in 1273a013, so the
exact class="aksrt-inv-bulk" string match no longer held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-01 00:22:44 +08:00

238 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
let rendered;
function harness() {
if (!rendered) {
const result = spawnSync('php', [path.join(__dirname, 'invite-admin-harness.php')], { encoding: 'utf8' });
assert.equal(result.status, 0, `${result.stderr}${result.stdout}`);
rendered = JSON.parse(result.stdout);
}
return rendered;
}
function dialog(html, id) {
const match = html.match(new RegExp(`<dialog\\b[^>]*\\bid="${id}"[^>]*>[\\s\\S]*?<\\/dialog>`));
assert.ok(match, `missing ${id} dialog`);
return match[0];
}
function input(html, name) {
const match = html.match(new RegExp(`<input\\b[^>]*\\bname="${name}"[^>]*>`));
assert.ok(match, `missing input ${name}`);
return match[0];
}
function fieldset(html, dependency) {
const tags = [...html.matchAll(/<fieldset\b[^>]*data-aksrt-inv-dep="([^"]+)"[^>]*>/g)];
const found = tags.find(([, value]) => value === dependency);
assert.ok(found, `missing conditional fieldset ${dependency}`);
return found[0];
}
function attr(tag, name, value) {
assert.match(tag, new RegExp(`\\b${name}="${value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}"`));
}
function hasBooleanAttr(tag, name) {
assert.match(tag, new RegExp(`(?:\\s|^)${name}(?:\\s|>|$)`));
}
test('filters, selection, table and pagination share one list card', () => {
const { html } = harness();
const cards = [...html.matchAll(/<section\b[^>]*class="[^"]*\baksrt-native-card\b[^"]*"[^>]*>[\s\S]*?<\/section>/g)];
assert.equal(cards.length, 1);
const card = cards[0][0];
assert.match(card, /aksrt-inv-list-card/);
assert.match(card, /<h2 class="aksrt-native-card__title">邀请码列表<\/h2>/);
for (const marker of ['class="subsubsub"', 'class="aksrt-native-filters"', '清除筛选', 'data-aksrt-inv-selection', 'aksrt-inv-table', 'class="tablenav bottom"']) {
assert.ok(card.includes(marker), `${marker} belongs to the list card`);
}
// 批量操作行同时带共享的 aksrt-native-bulk 类,按 class 列表中的词匹配,不要求整串相等。
assert.match(card, /class="[^"]*\baksrt-inv-bulk\b[^"]*"/, 'aksrt-inv-bulk belongs to the list card');
assert.doesNotMatch(html, /aksrt-inv-filter-summary|关键词:/);
assert.doesNotMatch(read('assets', 'css', 'admin', 'invite-codes.css'), /aksrt-inv-filter-summary/);
});
test('search and bulk actions remain independent sibling forms inside the list card', () => {
const { html } = harness();
let depth = 0;
let count = 0;
for (const [tag] of html.matchAll(/<\/?form\b[^>]*>/g)) {
if (tag.startsWith('</')) {
depth--;
} else {
count++;
depth++;
}
assert.ok(depth >= 0 && depth <= 1, 'forms must never nest');
}
assert.equal(depth, 0);
assert.equal(count, 4);
const searchEnd = html.indexOf('</form>', html.indexOf('class="aksrt-native-filters"'));
const listStart = html.lastIndexOf('<form', html.indexOf('data-aksrt-inv-list'));
assert.ok(searchEnd < listStart);
const listEnd = html.indexOf('</form>', listStart);
assert.ok(html.indexOf('class="tablenav bottom"', listStart) < listEnd);
assert.ok(listEnd < html.indexOf('</section>', listStart));
});
test('page renders add and export dialogs beside, not inside, the list form', () => {
const { html } = harness();
const listMarker = html.indexOf('data-aksrt-inv-list');
assert.ok(listMarker > 0, 'list form is rendered');
const listStart = html.lastIndexOf('<form', listMarker);
const listEnd = html.indexOf('</form>', listMarker);
const addStart = html.indexOf('<dialog', html.indexOf('id="aksrt-inv-add"') - 100);
const addDialog = dialog(html, 'aksrt-inv-add');
const exportDialog = dialog(html, 'aksrt-inv-export');
const addIndex = html.indexOf(addDialog);
const exportIndex = html.indexOf(exportDialog);
assert.ok(listStart >= 0 && listEnd > listStart);
assert.ok(listEnd < addIndex, 'the list form closes before the add dialog');
assert.ok(addIndex < exportIndex, 'add and export dialogs are rendered as adjacent page siblings');
assert.ok(html.indexOf('</dialog>', addIndex) < exportIndex);
assert.ok(addStart < 0 || addStart === addIndex, 'dialog extraction begins at its opening tag');
assert.doesNotMatch(addDialog, /<form[^>]*>[^]*<dialog/);
});
test('all admin forms carry the expected method, action, and escaped nonce', () => {
const { html } = harness();
const list = html.slice(html.lastIndexOf('<form', html.indexOf('data-aksrt-inv-list')), html.indexOf('</form>', html.indexOf('data-aksrt-inv-list')) + 7);
const add = dialog(html, 'aksrt-inv-add');
const exportForm = dialog(html, 'aksrt-inv-export');
assert.match(list, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(list, /name="action" value="aksrt_invite_delete"/);
for (const form of [list, add, exportForm]) {
assert.match(form, /name="aksrt_nonce" value="nonce&quot;&lt;script&gt;&amp;"/);
}
assert.match(add, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(add, /name="action" value="aksrt_invite_add"/);
assert.match(exportForm, /<form method="get" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(exportForm, /name="action" value="aksrt_invite_export"/);
});
test('rendered code, notice, user, date, and row values are escaped', () => {
const { html } = harness();
assert.match(html, /X&amp;Y&quot;&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /&lt;script&gt;date&lt;\/script&gt;/);
assert.match(html, /Admin&quot;&gt;&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /owner&quot;&gt;&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /Completed/);
assert.doesNotMatch(html, /<(?:script|img)\b|javascript:/i);
assert.match(html, /<br>/, 'the reward cell preserves the permitted line break');
});
test('conditional form sections start hidden and disabled where inactive', () => {
const { html } = harness();
const auto = fieldset(html, 'add_type=auto');
const importing = fieldset(html, 'add_type=import');
const codeLength = fieldset(html, 'auto_top_s=1');
const vipTime = fieldset(html, 'vip=1|2');
const textOptions = fieldset(html, 'export_format=text');
assert.doesNotMatch(auto, /\bhidden\b|\bdisabled\b/);
for (const pane of [importing, codeLength, vipTime, textOptions]) {
hasBooleanAttr(pane, 'hidden');
hasBooleanAttr(pane, 'disabled');
}
});
test('numeric fields expose browser constraints and VIP days apply to both levels', () => {
const { html } = harness();
const quantity = input(html, 'auto_num');
attr(quantity, 'min', '1'); attr(quantity, 'max', '1000'); attr(quantity, 'step', '1');
hasBooleanAttr(quantity, 'required');
const length = input(html, 'auto_rand_password_limit');
attr(length, 'min', '4'); attr(length, 'max', '50'); attr(length, 'step', '1');
const balance = input(html, 'balance');
attr(balance, 'min', '0'); attr(balance, 'step', '0\.01');
const points = input(html, 'points');
attr(points, 'min', '0'); attr(points, 'step', '1');
const days = input(html, 'vip_time');
attr(days, 'min', '1'); attr(days, 'step', '1');
hasBooleanAttr(days, 'required');
assert.match(html, /name="vip" value="1"/);
assert.match(html, /name="vip" value="2"/);
const vipPane = fieldset(html, 'vip=1|2');
const vipPaneStart = html.indexOf(vipPane);
assert.match(html.slice(vipPaneStart, html.indexOf('</fieldset>', vipPaneStart)), /name="vip_time"/);
});
test('export UI and service consistently name the spreadsheet format CSV', () => {
const { html } = harness();
const exportForm = dialog(html, 'aksrt-inv-export');
assert.match(exportForm, /name="export_format" value="csv" checked/);
assert.match(exportForm, /Excel 表格(\.csv)/);
assert.match(exportForm, /文本文档(\.txt)/);
const service = read('src', 'Payment', 'InviteCode.php');
assert.match(service, /\$_GET\['export_format'\] \?\? 'csv'\) === 'text' \? 'text' : 'csv'/);
assert.match(service, /\[__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\), __\('创建时间', 'aksrt'\), __\('更新时间', 'aksrt'\), __\('状态', 'aksrt'\), __\('标识', 'aksrt'\)\]/);
assert.match(service, /aksrt-invite-codes\.csv/);
assert.doesNotMatch(service, /\bxls\b|export_format[^\n]*xls/);
});
test('search form and filter links preserve sanitized keyword and remark context', () => {
const { html } = harness();
const marker = html.indexOf('class="aksrt-native-filters"');
const start = html.lastIndexOf('<form', marker);
const end = html.indexOf('</form>', marker);
const form = html.slice(start, end + 7);
assert.match(form, /<input type="hidden" name="status" value="used">/);
assert.match(form, /<input type="hidden" name="orderby" value="code">/);
assert.match(form, /<input type="hidden" name="desc" value="ASC">/);
assert.match(form, /<input type="search"[^>]*name="s" value="search alert\(2\) &amp; phrase"/);
const statusMatch = html.match(/<a href="([^"]+)"[^>]*>全部<\/a>/);
assert.ok(statusMatch, 'status filter link is rendered');
const statusUrl = new URL(statusMatch[1].replaceAll('&amp;', '&'));
assert.equal(statusUrl.pathname, '/wp-admin/users.php');
assert.equal(statusUrl.searchParams.get('status'), 'all');
assert.equal(statusUrl.searchParams.get('s'), 'search alert(2) & phrase');
assert.equal(statusUrl.searchParams.get('remark'), 'campaign & east');
const sortMatch = html.match(/<a class="orderby-but is-active" href="([^"]+)"/);
assert.ok(sortMatch, 'active sort link is rendered');
const sortUrl = new URL(sortMatch[1].replaceAll('&amp;', '&'));
assert.equal(sortUrl.searchParams.get('s'), 'search alert(2) & phrase');
assert.equal(sortUrl.searchParams.get('remark'), 'campaign & east');
assert.equal(sortUrl.searchParams.get('orderby'), 'code');
});
test('import rejects more than 1000 nonempty rows without truncation', () => {
const { over_limit_is_error, leading_blank_imported, import_insert_count } = harness();
assert.equal(over_limit_is_error, true);
assert.equal(leading_blank_imported, 1000);
assert.equal(import_insert_count, 1000, 'the over-limit request inserts nothing; leading blank rows do not count');
});
test('page uses no list/add/export tab route and service returns to the canonical list', () => {
const { html, return_url } = harness();
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php');
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php');
assert.doesNotMatch(page + forms + html, /page-title-action|[?&](?:tab|view)=(?:list|add|export)|tab=(?:list|add|export)/);
assert.equal(return_url, 'https://example.test/wp-admin/users.php?page=aksrt_invite_codes');
assert.doesNotMatch(return_url, /(?:tab|view|add)=/);
});
test('status-only list totals keep their filter and no longer return unused count buckets', () => {
const { list_result, row_queries } = harness();
assert.equal(Object.hasOwn(list_result, 'counts'), false);
const totalQuery = row_queries.find((sql) => /SELECT COUNT\(\*\) AS all_count/.test(sql));
assert.ok(totalQuery, 'the service issues a single total-count query');
assert.match(totalQuery, /WHERE used_count > 0/);
assert.doesNotMatch(totalQuery, /COALESCE|unused_count|used_count AS/);
assert.doesNotMatch(read('src', 'Payment', 'InviteCode.php'), /'counts'\s*=>/);
});
test('invite CSS and JS are enqueued only for the invite submenu hook', () => {
const { wrong_hook_assets, page_assets } = harness();
assert.deepEqual(wrong_hook_assets, []);
assert.equal(page_assets.length, 2);
assert.deepEqual(page_assets[0].slice(0, 4), [
'style', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/css/admin/invite-codes.css', ['aksrt-admin-native-ui'],
]);
assert.deepEqual(page_assets[1].slice(0, 4), [
'script', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/js/admin-invite-codes.js', [],
]);
});