Ctrl K
ComunidadGuías…

test/dashboard-account-selection.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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/);
  });
});