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>
238 lines
12 KiB
JavaScript
238 lines
12 KiB
JavaScript
'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"<script>&"/);
|
||
}
|
||
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&Y"<img src=x onerror=alert\(1\)>/);
|
||
assert.match(html, /<script>date<\/script>/);
|
||
assert.match(html, /Admin"><script>alert\(1\)<\/script>/);
|
||
assert.match(html, /owner"><img src=x onerror=alert\(1\)>/);
|
||
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\) & phrase"/);
|
||
const statusMatch = html.match(/<a href="([^"]+)"[^>]*>全部<\/a>/);
|
||
assert.ok(statusMatch, 'status filter link is rendered');
|
||
const statusUrl = new URL(statusMatch[1].replaceAll('&', '&'));
|
||
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('&', '&'));
|
||
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', [],
|
||
]);
|
||
});
|