Replace standalone add and export views with accessible dialogs, refine list interactions, and remove obsolete paths and unused counts. Fix filtered totals and reject oversized imports without truncation, with regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
10 KiB
JavaScript
199 lines
10 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('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', [],
|
||
]);
|
||
});
|