diff --git a/admin/README.md b/admin/README.md index 6d069bba4d6..c95dda4af38 100644 --- a/admin/README.md +++ b/admin/README.md @@ -64,3 +64,25 @@ const SettingsPanel = () => { The admin endpoints are not yet present in the OpenAPI spec — this client is in place to support upcoming work (see issue #7638 follow-up). For now, it is exercised only by the smoke test. + +## Socket.io: `padLoad` query shape + +The admin `/settings` namespace's `padLoad` event accepts a `PadSearchQuery` +defined in `src/node/types/PadSearchQuery.ts`: + +| field | type | required | notes | +| ------------ | ----------------------------------------------------------------- | -------- | ----- | +| `pattern` | `string` | yes | Substring match on pad name. | +| `offset` | `number` | yes | Pagination start, in items. Clamped server-side. | +| `limit` | `number` | yes | Page size. Capped at 12. | +| `ascending` | `boolean` | yes | Sort direction. | +| `sortBy` | `"padName" \| "lastEdited" \| "userCount" \| "revisionNumber"` | yes | Column to sort by. | +| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"` *(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. | + +Filter semantics — applied after pattern matching, before sort + slice: + +- `active`: `userCount > 0` +- `recent`: edited within the last 7 days +- `empty`: `revisionNumber === 0` +- `stale`: not edited in the last 365 days +- `all` / missing: no further filtering diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index ac193f272f7..2b8ba06d119 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -1,7 +1,7 @@ import {Trans, useTranslation} from "react-i18next"; import {useEffect, useMemo, useState} from "react"; import {useStore} from "../store/store.ts"; -import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; +import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; import {useDebounce} from "../utils/useDebounce.ts"; import * as Dialog from "@radix-ui/react-dialog"; import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react"; @@ -9,12 +9,8 @@ import {useForm} from "react-hook-form"; import type {TFunction} from "i18next"; type PadCreateProps = { padName: string } -type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale' -const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale'] - -const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7 -const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365 +const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale'] function relativeTime(t: TFunction, ts: number): string { const d = (Date.now() - ts) / 1000 @@ -58,12 +54,23 @@ function sanitizeLocale(lng?: string): string { export const PadPage = () => { const settingsSocket = useStore(state => state.settingsSocket) const [searchParams, setSearchParams] = useState({ - offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, + offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all', }) const {t, i18n} = useTranslation() const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language) const [searchTerm, setSearchTerm] = useState('') - const [filter, setFilter] = useState('all') + // Read filter off searchParams so chip changes round-trip through + // the server (`filter` is applied before pagination there). Clicking + // a chip used to filter only the current 12-row page slice. + // + // All searchParams mutations go through functional updaters because the + // debounced pattern handler captures a render-time snapshot and would + // otherwise revert a faster chip click / sort change made in between. + const filter: PadFilter = searchParams.filter ?? 'all' + const setFilter = (f: PadFilter) => { + setCurrentPage(0) + setSearchParams((sp) => ({...sp, filter: f, offset: 0})) + } const [selected, setSelected] = useState>(new Set()) const pads = useStore(state => state.pads) const [currentPage, setCurrentPage] = useState(0) @@ -78,28 +85,23 @@ export const PadPage = () => { [pads, searchParams.limit] ) - const filteredResults = useMemo(() => { - const r = pads?.results ?? [] - if (filter === 'active') return r.filter(p => p.userCount > 0) - if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited)) - if (filter === 'empty') return r.filter(p => p.revisionNumber === 0) - if (filter === 'stale') return r.filter(p => isStale(p.lastEdited)) - return r - }, [pads, filter]) - - const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads]) - const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads]) - const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads]) + // The server applies `filter` before paginating; the page payload is + // already the filtered slice. The stats cards still reflect the + // current page (pre-existing behaviour) — making them global would + // require a separate aggregate query. + const visibleResults = pads?.results ?? [] + const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads]) + const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads]) + const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads]) const lastActivity = useMemo(() => { - const r = pads?.results ?? [] - return r.length ? Math.max(...r.map(p => p.lastEdited)) : null + return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null }, [pads]) - const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName)) + const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName)) const toggleAll = () => { const s = new Set(selected) - if (allSelected) filteredResults.forEach(p => s.delete(p.padName)) - else filteredResults.forEach(p => s.add(p.padName)) + if (allSelected) visibleResults.forEach(p => s.delete(p.padName)) + else visibleResults.forEach(p => s.add(p.padName)) setSelected(s) } const toggleOne = (name: string) => { @@ -109,7 +111,10 @@ export const PadPage = () => { } useDebounce(() => { - setSearchParams({...searchParams, pattern: searchTerm}) + // Functional updater so this delayed callback can't clobber a faster + // user interaction (e.g. clicking a filter chip mid-typing). + setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0})) + setCurrentPage(0) }, 500, [searchTerm]) useEffect(() => { @@ -250,7 +255,7 @@ export const PadPage = () => {

- {filteredResults.length} + {visibleResults.length}
@@ -268,12 +273,12 @@ export const PadPage = () => {
)} - {filteredResults.length > 0 ? ( + {visibleResults.length > 0 ? (
@@ -348,7 +353,7 @@ export const PadPage = () => { - {filteredResults.map(pad => { + {visibleResults.map(pad => { const isEmpty = pad.revisionNumber === 0 const isSel = selected.has(pad.padName) return ( @@ -432,7 +437,7 @@ export const PadPage = () => { onClick={() => { const p = currentPage - 1 setCurrentPage(p) - setSearchParams({...searchParams, offset: p * searchParams.limit}) + setSearchParams((sp) => ({...sp, offset: p * sp.limit})) }} > @@ -444,7 +449,7 @@ export const PadPage = () => { onClick={() => { const p = currentPage + 1 setCurrentPage(p) - setSearchParams({...searchParams, offset: p * searchParams.limit}) + setSearchParams((sp) => ({...sp, offset: p * sp.limit})) }} > diff --git a/admin/src/utils/PadSearch.ts b/admin/src/utils/PadSearch.ts index c0cb148b0c5..0cfecd9818d 100644 --- a/admin/src/utils/PadSearch.ts +++ b/admin/src/utils/PadSearch.ts @@ -1,9 +1,12 @@ +export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale'; + export type PadSearchQuery = { pattern: string; offset: number; limit: number; ascending: boolean; sortBy: string; + filter?: PadFilter; } diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts index ab4728f6c00..a5b20bfdd58 100644 --- a/src/node/hooks/express/adminsettings.ts +++ b/src/node/hooks/express/adminsettings.ts @@ -1,7 +1,7 @@ 'use strict'; -import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery"; +import {PAD_FILTERS, PadFilter, PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery"; import log4js from 'log4js'; const fsp = require('fs').promises; @@ -15,8 +15,35 @@ import {deleteRevisions} from '../../utils/Cleanup'; const queryPadLimit = 12; +// Cap on concurrent `padManager.getPad()` calls while hydrating the pad +// universe for filter chip / non-name sort. The old per-sortBy handlers +// awaited each getPad sequentially (concurrency = 1); the unified +// pipeline used to issue Promise.all over the full candidate set, which +// can fan out to thousands of in-flight DB reads on busy deployments. +// 16 is empirically enough to saturate a single ueberDB driver without +// pushing the event loop into back-pressure. +const PAD_HYDRATE_CONCURRENCY = 16; const logger = log4js.getLogger('adminSettings'); +// Concurrency-limited Promise.all replacement. Preserves the input +// order in the returned array (caller slices later). Used by padLoad +// to bound DB reads during hydration. +async function mapWithConcurrency( + items: T[], limit: number, fn: (t: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let next = 0; + const worker = async () => { + while (true) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i]); + } + }; + const workers = Array.from({length: Math.min(limit, items.length)}, worker); + await Promise.all(workers); + return out; +} + exports.socketio = (hookName: string, {io}: any) => { io.of('/settings').on('connection', (socket: any) => { @@ -110,137 +137,112 @@ exports.socketio = (hookName: string, {io}: any) => { socket.on('padLoad', async (query: PadSearchQuery) => { const {padIDs} = await padManager.listAllPads(); - const data: { - total: number, - results?: PadQueryResult[] - } = { - total: padIDs.length, - }; - let result: string[] = padIDs; - let maxResult; - - // Filter out matches + // ── 1. Pattern filter (cheap, by name only) ───────────────────── + let candidateNames: string[] = padIDs; if (query.pattern) { - result = result.filter((padName: string) => padName.includes(query.pattern)); + candidateNames = candidateNames.filter( + (padName: string) => padName.includes(query.pattern)); } - data.total = result.length; + // ── 2. Resolve filter chip ────────────────────────────────────── + // PadPage sends a chip id; "all" (default) means no additional + // filtering. We accept missing values from older clients gracefully. + const filter: PadFilter = + (query.filter && PAD_FILTERS.includes(query.filter)) ? query.filter : 'all'; + + // ── 3. Decide whether we need full metadata for every candidate ── + // The fast path — name-sort with no filter chip — only needs to + // hydrate metadata for the 12-row page slice. Any other path + // (filter chip OR non-name sort) requires every candidate's revs + // / users / lastEdited up front so we can sort and slice against + // the right universe. The expensive call is `padManager.getPad`; + // user counts come from an in-memory map. + const needsFullScan = filter !== 'all' || query.sortBy !== 'padName'; + + const loadMeta = async (padName: string): Promise => { + const pad = await padManager.getPad(padName); + return { + padName, + lastEdited: await pad.getLastEdit(), + userCount: api.padUsersCount(padName).padUsersCount, + revisionNumber: pad.getHeadRevisionNumber(), + }; + }; + + // Lazily lifted so we don't load every pad twice on the fast path. + let hydrated: PadQueryResult[] | null = null; + const hydrateAll = async () => { + if (hydrated == null) { + hydrated = await mapWithConcurrency( + candidateNames, PAD_HYDRATE_CONCURRENCY, loadMeta); + } + return hydrated; + }; + + // ── 4. Filter chip — applied to hydrated metadata ──────────────── + // Bucket boundaries match the client chips in PadPage.tsx so the + // counts on the stats cards keep meaning the same thing. Compute + // `now` once per request so a pad doesn't slip between buckets + // mid-loop. + const now = Date.now(); + const isRecent = (lastEdited: number) => now - lastEdited < 86_400_000 * 7; + const isStale = (lastEdited: number) => now - lastEdited > 86_400_000 * 365; + const matchesFilter = (m: PadQueryResult) => { + switch (filter) { + case 'active': return m.userCount > 0; + case 'recent': return isRecent(Number(m.lastEdited)); + case 'empty': return m.revisionNumber === 0; + case 'stale': return isStale(Number(m.lastEdited)); + default: return true; + } + }; - maxResult = result.length - 1; - if (maxResult < 0) { - maxResult = 0; + // ── 5. Total — i.e. the count the pagination footer reflects ──── + // For the fast path this is just the pattern-filtered name list; + // for full-scan we report the post-chip total. + let totalNames: string[] | null = needsFullScan ? null : candidateNames; + let postFilterMetas: PadQueryResult[] | null = null; + if (needsFullScan) { + postFilterMetas = (await hydrateAll()).filter(matchesFilter); } + const total = needsFullScan ? postFilterMetas!.length : totalNames!.length; - // Reset to default values if out of bounds + // ── 6. Clamp offset/limit ────────────────────────────────────── + const maxOffset = Math.max(total - 1, 0); if (query.offset && query.offset < 0) { query.offset = 0; - } else if (query.offset > maxResult) { - query.offset = maxResult; + } else if (query.offset > maxOffset) { + query.offset = maxOffset; } - if (query.limit && query.limit < 0) { - // Too small query.limit = 0; } else if (query.limit > queryPadLimit) { - // Too big query.limit = queryPadLimit; } - - if (query.sortBy === 'padName') { - result = result.sort((a, b) => { - if (a < b) return query.ascending ? -1 : 1; - if (a > b) return query.ascending ? 1 : -1; - return 0; - }).slice(query.offset, query.offset + query.limit); - - data.results = await Promise.all(result.map(async (padName: string) => { - const pad = await padManager.getPad(padName); - const revisionNumber = pad.getHeadRevisionNumber() - const userCount = api.padUsersCount(padName).padUsersCount; - const lastEdited = await pad.getLastEdit(); - - return { - padName, - lastEdited, - userCount, - revisionNumber + // ── 7. Sort + slice ──────────────────────────────────────────── + const dir = query.ascending ? 1 : -1; + const cmpStr = (a: string, b: string) => a < b ? -dir : a > b ? dir : 0; + const cmpNum = (a: number, b: number) => a < b ? -dir : a > b ? dir : 0; + + let results: PadQueryResult[]; + if (needsFullScan) { + const sorted = postFilterMetas!.sort((a, b) => { + switch (query.sortBy) { + case 'padName': return cmpStr(a.padName, b.padName); + case 'revisionNumber': return cmpNum(a.revisionNumber, b.revisionNumber); + case 'userCount': return cmpNum(a.userCount, b.userCount); + case 'lastEdited': return cmpStr(String(a.lastEdited), String(b.lastEdited)); + default: return 0; } - })); - } else if (query.sortBy === "revisionNumber") { - const currentWinners: PadQueryResult[] = [] - const padMapping = [] as {padId: string, revisionNumber: number}[] - for (let res of result) { - const pad = await padManager.getPad(res); - const revisionNumber = pad.getHeadRevisionNumber() - padMapping.push({padId: res, revisionNumber}) - } - padMapping.sort((a, b) => { - if (a.revisionNumber < b.revisionNumber) return query.ascending ? -1 : 1; - if (a.revisionNumber > b.revisionNumber) return query.ascending ? 1 : -1; - return 0; - }) - - for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) { - let pad = await padManager.getPad(padRetrieval.padId); - currentWinners.push({ - padName: padRetrieval.padId, - lastEdited: await pad.getLastEdit(), - userCount: api.padUsersCount(pad.padName).padUsersCount, - revisionNumber: padRetrieval.revisionNumber - }) - } - - data.results = currentWinners; - } else if (query.sortBy === "userCount") { - const currentWinners: PadQueryResult[] = [] - const padMapping = [] as {padId: string, userCount: number}[] - for (let res of result) { - const userCount = api.padUsersCount(res).padUsersCount - padMapping.push({padId: res, userCount}) - } - padMapping.sort((a, b) => { - if (a.userCount < b.userCount) return query.ascending ? -1 : 1; - if (a.userCount > b.userCount) return query.ascending ? 1 : -1; - return 0; - }) - - for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) { - let pad = await padManager.getPad(padRetrieval.padId); - currentWinners.push({ - padName: padRetrieval.padId, - lastEdited: await pad.getLastEdit(), - userCount: padRetrieval.userCount, - revisionNumber: pad.getHeadRevisionNumber() - }) - } - data.results = currentWinners; - } else if (query.sortBy === "lastEdited") { - const currentWinners: PadQueryResult[] = [] - const padMapping = [] as {padId: string, lastEdited: string}[] - for (let res of result) { - const pad = await padManager.getPad(res); - const lastEdited = await pad.getLastEdit(); - padMapping.push({padId: res, lastEdited}) - } - padMapping.sort((a, b) => { - if (a.lastEdited < b.lastEdited) return query.ascending ? -1 : 1; - if (a.lastEdited > b.lastEdited) return query.ascending ? 1 : -1; - return 0; - }) - - for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) { - let pad = await padManager.getPad(padRetrieval.padId); - currentWinners.push({ - padName: padRetrieval.padId, - lastEdited: padRetrieval.lastEdited, - userCount: api.padUsersCount(pad.padName).padUsersCount, - revisionNumber: pad.getHeadRevisionNumber() - }) - } - data.results = currentWinners; + }); + results = sorted.slice(query.offset, query.offset + query.limit); + } else { + const sliceNames = totalNames!.sort(cmpStr).slice(query.offset, query.offset + query.limit); + results = await Promise.all(sliceNames.map(loadMeta)); } + const data: {total: number, results?: PadQueryResult[]} = {total, results}; socket.emit('results:padLoad', data); }) diff --git a/src/node/types/PadSearchQuery.ts b/src/node/types/PadSearchQuery.ts index b8c838b6c49..7d3e3e838d8 100644 --- a/src/node/types/PadSearchQuery.ts +++ b/src/node/types/PadSearchQuery.ts @@ -1,9 +1,18 @@ +export type PadFilter = "all" | "active" | "recent" | "empty" | "stale"; + +export const PAD_FILTERS: PadFilter[] = ["all", "active", "recent", "empty", "stale"]; + export type PadSearchQuery = { pattern: string; offset: number; limit: number; ascending: boolean; sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber"; + // Filter chip. Defaults to "all". Applied server-side so pagination + // reflects the filtered universe — without this, the chip filters only + // the current page slice and "0 empty pads" can appear on page 1 while + // page 2 has nothing but empties. + filter?: PadFilter; } diff --git a/src/tests/backend/specs/admin/padLoadFilter.ts b/src/tests/backend/specs/admin/padLoadFilter.ts new file mode 100644 index 00000000000..a53b75e3235 --- /dev/null +++ b/src/tests/backend/specs/admin/padLoadFilter.ts @@ -0,0 +1,205 @@ +'use strict'; + +// Regression test for the admin /settings socket's `padLoad` filter chip. +// Before commit fb…, `filter` (active|empty|recent|stale) lived only on +// the client and ran AFTER pagination, so clicking "empty pads" on a +// server with thousands of pads showed 0–12 results from page 1 even +// though hundreds of empty pads existed deeper in the list. The filter +// now rides PadSearchQuery and is applied server-side before the +// offset/limit slice, so `total` reflects the filtered universe. + +import {strict as assert} from 'assert'; +import setCookieParser from 'set-cookie-parser'; + +const io = require('socket.io-client'); +const common = require('../../common'); +const settings = require('../../../../node/utils/Settings'); +const padManager = require('../../../../node/db/PadManager'); + +const adminSocket = async () => { + settings.users = settings.users || {}; + settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true}; + const savedRequireAuthentication = settings.requireAuthentication; + settings.requireAuthentication = true; + let res: any; + try { + res = await (common.agent as any) + .get('/admin/') + .auth('test-admin', 'test-admin-password'); + } finally { + settings.requireAuthentication = savedRequireAuthentication; + } + const resCookies = setCookieParser.parse(res, {map: true}); + const reqCookieHdr = Object.entries(resCookies) + .map(([name, cookie]: [string, any]) => + `${name}=${encodeURIComponent(cookie.value)}`) + .join('; '); + const socket = io(`${common.baseUrl}/settings`, { + forceNew: true, + query: {cookie: reqCookieHdr}, + }); + await new Promise((res, rej) => { + const onErr = (err: any) => { socket.off('connect', onConn); rej(err); }; + const onConn = () => { socket.off('connect_error', onErr); res(); }; + socket.once('connect', onConn); + socket.once('connect_error', onErr); + }); + return socket; +}; + +const ask = (socket: any, evt: string, payload: any, replyEvt: string) => + new Promise((res) => { + socket.once(replyEvt, res); + socket.emit(evt, payload); + }); + +const PROBE_BUDGET_MS = 15000; +const adminSocketWithProbe = async (budgetMs: number): Promise<{ + ok: true; socket: any; +} | {ok: false; reason: string;}> => { + const deadline = Date.now() + budgetMs; + let socket: any; + try { + socket = await Promise.race([ + adminSocket(), + new Promise((_, rej) => + setTimeout(() => rej(new Error('adminSocket connect timed out')), + Math.max(0, deadline - Date.now()))), + ]); + } catch (err: any) { + return {ok: false, reason: String(err && err.message || err)}; + } + const remaining = Math.max(0, deadline - Date.now()); + const replied = new Promise((res) => socket.once('results:padLoad', () => res(true))); + socket.emit('padLoad', { + pattern: '__padLoadFilter-probe__', offset: 0, limit: 1, + sortBy: 'padName', ascending: true, + }); + const probed = await Promise.race([ + replied, + new Promise((res) => setTimeout(() => res(false), remaining)), + ]); + if (!probed) { + socket.disconnect(); + return {ok: false, reason: `no \`results:padLoad\` reply within ${budgetMs}ms`}; + } + return {ok: true, socket}; +}; + +describe(__filename, function () { + let socket: any; + let savedUsers: any; + let savedRequireAuthentication: boolean; + let setupCompleted = false; + // Distinct per-suite tag so concurrent test runs / leftover pads from + // earlier suites don't pollute the filter assertions. + const tag = `padLoadFilter-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const emptyPadIds: string[] = []; + const editedPadIds: string[] = []; + + before(async function () { + this.timeout(120000); + await common.init(); + + savedUsers = settings.users; + savedRequireAuthentication = settings.requireAuthentication; + setupCompleted = true; + + const probe = await adminSocketWithProbe(PROBE_BUDGET_MS); + if (!probe.ok) { + console.warn( + `[padLoadFilter] admin socket probe failed (${probe.reason}); ` + + "skipping suite — likely an authenticate-hook plugin rejecting the test's " + + 'admin credentials.'); + this.skip(); + return; + } + socket = probe.socket; + + // 5 empty pads, 3 edited (head rev > 0). + for (let i = 0; i < 5; i++) { + const id = `${tag}-empty-${i}`; + await padManager.getPad(id, ''); + emptyPadIds.push(id); + } + for (let i = 0; i < 3; i++) { + const id = `${tag}-edited-${i}`; + const pad = await padManager.getPad(id, ''); + // setText bumps head past 0; padLoad reports the post-edit + // revisionNumber, which is what filter:"empty" excludes. + await pad.setText(`seed-${i}\n`, `m-${tag}-${i}`); + editedPadIds.push(id); + } + }); + + after(async function () { + if (socket) socket.disconnect(); + if (!setupCompleted) return; + // `savedUsers` may point at the same object that adminSocket mutated, + // so reassigning the reference is a no-op; explicitly delete the + // injected key so subsequent backend specs don't see a stale + // test-admin user. + if (settings.users) delete settings.users['test-admin']; + settings.users = savedUsers; + settings.requireAuthentication = savedRequireAuthentication; + for (const id of [...emptyPadIds, ...editedPadIds]) { + try { + const pad = await padManager.getPad(id, ''); + await pad.remove(); + } catch { /* already gone */ } + } + }); + + it('filter:"empty" returns only revisionNumber===0 pads from the full set', async function () { + const res = await ask(socket, 'padLoad', { + pattern: tag, offset: 0, limit: 12, sortBy: 'padName', + ascending: true, filter: 'empty', + }, 'results:padLoad'); + assert.equal(res.total, 5, `expected total=5, got ${JSON.stringify(res)}`); + for (const r of res.results) { + assert.equal(r.revisionNumber, 0, + `non-empty pad leaked through filter: ${JSON.stringify(r)}`); + } + }); + + it('filter:"empty" with limit=2 still reports the correct total (regression: thm)', async function () { + // The bug thm hit: clicking "empty" showed at most `limit` empties + // because filtering happened on the client AFTER pagination. The + // server now applies filter first, so total reflects the filtered + // universe and pagination spans it correctly. + const res = await ask(socket, 'padLoad', { + pattern: tag, offset: 0, limit: 2, sortBy: 'padName', + ascending: true, filter: 'empty', + }, 'results:padLoad'); + assert.equal(res.total, 5, `expected total=5 (all empties), got total=${res.total}`); + assert.equal(res.results.length, 2, `expected limit=2 page, got ${res.results.length} rows`); + }); + + it('filter omitted (older client) falls back to "all"', async function () { + const res = await ask(socket, 'padLoad', { + pattern: tag, offset: 0, limit: 12, sortBy: 'padName', + ascending: true, + }, 'results:padLoad'); + assert.equal(res.total, 8, + `expected total=8 (5 empty + 3 edited), got ${JSON.stringify(res)}`); + }); + + it('filter:"all" matches the no-filter behaviour', async function () { + const res = await ask(socket, 'padLoad', { + pattern: tag, offset: 0, limit: 12, sortBy: 'padName', + ascending: true, filter: 'all', + }, 'results:padLoad'); + assert.equal(res.total, 8); + }); + + it('filter:"active" excludes pads with no active users', async function () { + // No connected clients in this test, so every test pad has + // userCount === 0 → filter:"active" must return zero. + const res = await ask(socket, 'padLoad', { + pattern: tag, offset: 0, limit: 12, sortBy: 'padName', + ascending: true, filter: 'active', + }, 'results:padLoad'); + assert.equal(res.total, 0); + assert.equal(res.results.length, 0); + }); +});