Files
wordpress-theme-aksrt/tests/widget-system/invite-admin-contract.test.cjs
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +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-jio-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="[^"]*\bjio-native-card\b[^"]*"[^>]*>[\s\S]*?<\/section>/g)];
assert.equal(cards.length, 1);
const card = cards[0][0];
assert.match(card, /jio-inv-list-card/);
assert.match(card, /<h2 class="jio-native-card__title">邀请码列表<\/h2>/);
for (const marker of ['class="subsubsub"', 'class="jio-native-filters"', '清除筛选', 'data-jio-inv-selection', 'jio-inv-table', 'class="tablenav bottom"']) {
assert.ok(card.includes(marker), `${marker} belongs to the list card`);
}
// 批量操作行同时带共享的 jio-native-bulk 类,按 class 列表中的词匹配,不要求整串相等。
assert.match(card, /class="[^"]*\bjio-inv-bulk\b[^"]*"/, 'jio-inv-bulk belongs to the list card');
assert.doesNotMatch(html, /jio-inv-filter-summary|关键词:/);
assert.doesNotMatch(read('assets', 'css', 'admin', 'invite-codes.css'), /jio-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="jio-native-filters"'));
const listStart = html.lastIndexOf('<form', html.indexOf('data-jio-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-jio-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="jio-inv-add"') - 100);
const addDialog = dialog(html, 'jio-inv-add');
const exportDialog = dialog(html, 'jio-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-jio-inv-list')), html.indexOf('</form>', html.indexOf('data-jio-inv-list')) + 7);
const add = dialog(html, 'jio-inv-add');
const exportForm = dialog(html, 'jio-inv-export');
assert.match(list, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(list, /name="action" value="jio_invite_delete"/);
for (const form of [list, add, exportForm]) {
assert.match(form, /name="jio_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="jio_invite_add"/);
assert.match(exportForm, /<form method="get" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(exportForm, /name="action" value="jio_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, 'jio-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, /\[__\('邀请码', 'jio'\), __\('使用奖励', 'jio'\), __\('创建时间', 'jio'\), __\('更新时间', 'jio'\), __\('状态', 'jio'\), __\('标识', 'jio'\)\]/);
assert.match(service, /jio-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="jio-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=jio_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', 'jio-admin-invite-codes', 'https://example.test/theme/assets/css/admin/invite-codes.css', ['jio-admin-native-ui'],
]);
assert.deepEqual(page_assets[1].slice(0, 4), [
'script', 'jio-admin-invite-codes', 'https://example.test/theme/assets/js/admin-invite-codes.js', [],
]);
});