test/dashboard-account-selection.test.js
Rama master · réplica de lectura del repositorio original.
// Selection safety for the merged 账号管理 page (Q4 G1).
//
// The defect this file pins: `_selectedAccounts` used to survive filter/search
// changes, so a 50-id selection made before filtering was still live after the
// table showed 3 rows — and `POST /accounts/batch {op:'delete'}` deleted all 50.
//
// These tests execute the SHIPPED methods (brace-sliced out of index.html), not
// a mirror implementation: a stub `self` supplies only the collaborators the
// method calls, so the filter-key guard and the delete scope are the real ones.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const HTML = readFileSync(new URL('../src/dashboard/index.html', import.meta.url), 'utf8');
function methodParts(name) {
let at = HTML.indexOf(`\n ${name}(`);
if (at === -1) at = HTML.indexOf(`\n async ${name}(`);
assert.notEqual(at, -1, `${name} must exist as a method in the dashboard App object`);
const open = HTML.indexOf('{', at);
const decl = HTML.slice(at + 3, open);
const params = HTML.slice(HTML.indexOf(name, at) + name.length, open).trim();
const isAsync = /^async\s/.test(decl);
let depth = 0;
let end = -1;
for (let i = open; i < HTML.length; i++) {
if (HTML[i] === '{') depth++;
else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
}
assert.notEqual(end, -1, `could not delimit the body of ${name}`);
return { params, body: HTML.slice(open, end), isAsync };
}
const I18n = {
t: (key, vars) => `${key}|${JSON.stringify(vars || {})}`,
};
function method(name, self) {
const { params, body, isAsync } = methodParts(name);
// eslint-disable-next-line no-new-func -- executing the shipped source is the point
const fn = new Function('I18n', 'self', '...args',
`return (${isAsync ? 'async ' : ''}function ${name}${params} ${body}).apply(self, args);`);
return (...args) => fn(I18n, self, ...args);
}
function baseSelf(overrides = {}) {
const calls = { toast: [], clear: 0, loads: 0, api: [] };
const self = {
accountsQuery: '',
accountsTier: '',
accountsStatus: '',
accountsFlagged: false,
_selectedAccounts: new Set(),
_selectionMode: 'ids',
_selectionFilterKey: null,
toast: (...args) => calls.toast.push(args),
clearAccountSelection: () => {
calls.clear++;
self._selectedAccounts.clear();
self._selectionMode = 'ids';
self._selectionFilterKey = null;
},
loadAccounts: async () => { calls.loads++; },
api: async (methodName, path, body) => {
calls.api.push({ methodName, path, body });
return { success: true, ids: [], results: [] };
},
_syncSelectAllState: () => {},
_updateBatchBar: () => {},
...overrides,
};
self.accountsFilterKey = method('accountsFilterKey', self);
self._selectionMatchesFilters = method('_selectionMatchesFilters', self);
self._rememberSelectionScope = method('_rememberSelectionScope', self);
self._dropStaleSelection = method('_dropStaleSelection', self);
return { self, calls };
}
describe('selection is scoped to the filter that produced it', () => {
it('changes the filter key for every server param', () => {
const { self } = baseSelf();
const base = self.accountsFilterKey();
for (const patch of [{ accountsQuery: 'a' }, { accountsTier: 'pro' }, { accountsStatus: 'banned' }, { accountsFlagged: true }]) {
const other = baseSelf({ ...patch });
assert.notEqual(other.self.accountsFilterKey(), base, `filter key must change for ${JSON.stringify(patch)}`);
}
});
it('clears the selection when the status filter changes', () => {
const { self, calls } = baseSelf();
self._selectedAccounts.add('hidden-row');
self._selectionFilterKey = self.accountsFilterKey();
method('setAccountsStatus', self)('banned');
assert.equal(self.accountsStatus, 'banned');
assert.equal(calls.clear, 1, 'a filter change must clear the armed selection');
assert.equal(self._selectedAccounts.size, 0);
assert.equal(calls.loads, 1);
});
it('clears the selection at search-input time, before the 300 ms reload', () => {
const { self, calls } = baseSelf({ accountsQuery: 'old' });
self._selectedAccounts.add('hidden-row');
self._selectionFilterKey = self.accountsFilterKey();
method('onAccountsSearchInput', self)('new');
assert.equal(calls.clear, 1, 'delete must not act during the debounce window');
assert.equal(self.accountsQuery, 'new');
});
it('drops a stale selection on reload and tells the operator', () => {
const { self, calls } = baseSelf({ accountsStatus: 'banned' });
self._selectedAccounts.add('hidden-row');
self._selectionFilterKey = 'a-filter-that-no-longer-matches';
assert.equal(self._dropStaleSelection(), true);
assert.equal(calls.clear, 1);
assert.equal(self._selectedAccounts.size, 0);
assert.match(calls.toast[0][0], /accounts\.batch\.selectionCleared/);
});
it('remembers "select all matching" as an explicit all scope', async () => {
const { self } = baseSelf({ accountsStatus: 'banned' });
const selectAll = method('selectAllMatching', self);
const prevDocument = globalThis.document;
globalThis.document = { querySelectorAll: () => [] };
try {
self.api = async () => ({ ids: ['a', 'b'] });
await selectAll();
assert.equal(self._selectionMode, 'all');
assert.equal(self._selectionFilterKey, self.accountsFilterKey());
assert.deepEqual([...self._selectedAccounts], ['a', 'b']);
} finally {
globalThis.document = prevDocument;
}
});
it('refuses an oversized "select all" without changing the current scope', async () => {
const { self, calls } = baseSelf();
self._selectionMode = 'ids';
self._selectionFilterKey = self.accountsFilterKey();
self.api = async () => ({ ids: Array.from({ length: 1001 }, (_, i) => `id-${i}`) });
const selectAll = method('selectAllMatching', self);
const prevDocument = globalThis.document;
globalThis.document = { querySelectorAll: () => [] };
try {
await selectAll();
} finally {
globalThis.document = prevDocument;
}
assert.equal(self._selectionMode, 'ids');
assert.equal(calls.toast[0][0], 'accounts.batch.tooMany|{"max":1000}');
});
});
describe('batch delete only deletes the confirmed scope', () => {
it('sends exactly the selected ids and names the scope in the confirmation', async () => {
const { self, calls } = baseSelf();
self._selectedAccounts = new Set(['a', 'b']);
self._selectionMode = 'ids';
self._selectionFilterKey = self.accountsFilterKey();
let confirmDesc = '';
self.confirm = async (title, desc) => { confirmDesc = desc; return true; };
self._batchResults = new Map();
self.setBatchProgress = () => {};
self.renderBatchFailures = () => {};
self._accountDetails = new Map();
self._cardsByAccount = new Map();
self.translateError = (e) => e;
self.api = async (methodName, path, body) => {
calls.api.push({ methodName, path, body });
return {
success: true,
results: body.ids.map((id) => ({ id, ok: true })),
failed: [], skipped: [], okCount: body.ids.length, failCount: 0, skippedCount: 0, attempted: body.ids.length,
};
};
const batch = method('batchAccountOp', self);
await batch('delete');
assert.equal(calls.api.length, 1);
assert.equal(calls.api[0].path, '/accounts/batch');
assert.deepEqual(calls.api[0].body, { ids: ['a', 'b'], op: 'delete' });
assert.match(confirmDesc, /accounts\.batch\.scopeSelected/);
});
it('aborts a delete whose selection belongs to a different filter', async () => {
const { self, calls } = baseSelf({ accountsStatus: 'banned' });
self._selectedAccounts = new Set(['hidden-row']);
self._selectionFilterKey = 'the-filter-before-the-change';
const batch = method('batchAccountOp', self);
await batch('delete');
assert.equal(calls.api.length, 0, 'no batch request may run with a stale scope');
assert.equal(calls.clear, 1);
assert.equal(calls.toast[0][1], 'error');
assert.match(calls.toast[0][0], /accounts\.batch\.selectionCleared/);
});
});