Skip to content

Commit 051b428

Browse files
authored
feat(admin-groups): render per-group server cards with live-hostname hydration (#1406) (#1413)
* feat(admin-groups): render per-group server cards with live-hostname hydration (#1406) Closes #1406. The Server Groups list (?p=admin&c=groups) used to ship a per-card hydration surface (`<div id="servers_{gid}">` + a legacy `LoadServerHostPlayersList(...)` script feeder) that was silently dead post-#1123 D1 — every page load raised ReferenceError once per group, and admins saw a literal "Servers populate via the legacy ... hook." placeholder forever. Cleanup PR #1404 dropped the dead echo + slot + placeholder; this is the additive replacement. Each group card now emits one `[data-testid="server-tile"]` per bound server inside a `[data-server-hydrate="auto"]` container, picked up by the shared `server-tile-hydrate.js` helper (the same one the public Server List #1311 / admin Server Management #1313 / dashboard widget fallback inside `[data-testid="server-host"]`. Implementation: - `web/pages/admin.groups.php`: INNER JOIN `:prefix_servers_groups` against `:prefix_servers` per group so each `$server_list` row carries a `servers` array of (sid, ip, port). Dangling membership rows from deleted servers silently drop instead of surfacing broken tiles. ORDER BY S.sid keeps the render order stable across page loads. - `web/includes/View/AdminGroupsListView.php`: docblock update for the new `servers` shape. - `web/themes/default/page_admin_groups_list.tpl`: per-group `<ul data-server-hydrate="auto">` with one `<li data-testid="server-tile" data-id="{$server.sid}">` per bound server. `data-trunchostname="40"` matches the dashboard widget's cramped-card tuning. Empty-state arm gates on `empty($group.servers)` to show a `[data-testid="server-group-empty"]` one-liner instead of an inert hydration container. - No new JSON action: the existing `Actions.ServersHostPlayers` is what the shared hydration helper fires per tile, same as every other surface that rides this helper. Tests: - `web/tests/integration/AdminServerGroupsServerCardsRenderTest.php` pins the static contract (mirror of `ServerMapImageRenderTest`): handler JOIN + column list + `$row['servers']` assignment, template testids + data-fallback shape + hydration helper script include, hydration helper's `server-host` wiring, and the "dead shape stays dead" sister contract (no `LoadServerHostPlayersList`, no `<div id="servers_{gid}">`, no placeholder copy). - `web/tests/e2e/specs/flows/admin-groups-server-cards-hydration.spec.ts` drives the runtime observable: seeds a server group with two bound servers via the new `seedServerGroupWithServersE2e` shim, stalls `Actions.ServersHostPlayers` per tile via `page.route`, asserts the SSR IP:port fallback paints, releases the stub, and asserts each `[data-testid="server-host"]` flips to its canned live hostname. - `web/tests/e2e/scripts/seed-server-group-e2e.php` + the matching `seedServerGroupWithServersE2e` helper in `web/tests/e2e/fixtures/db.ts`: shells out to write the three rows (`:prefix_groups (type=3)` + N `:prefix_servers` + N `:prefix_servers_groups`) directly. There is no JSON action to wire a server into a server-group's membership, so the seed has to go via SQL. Same e2e-only guardrails as the sister `seed-comms-e2e.php` / `set-setting-e2e.php` shims (refuses any DB other than the e2e schema). Docs: AGENTS.md "Hydrate server-tile cards…" row in "Where to find what" updated to enumerate the Server Groups card stack alongside the public/admin/dashboard surfaces, with the per-group `servers` array + INNER JOIN composition documented. Refs #1404, #1311, #1313, #1375, #1405. * chore(admin-groups): skip disabled-server probes + extend e2e coverage (review) Adversarial reviewer findings on PR #1413 (issue #1406): 1. Disabled servers were rendered AND probed by server-tile-hydrate. Mirror the sibling page_admin_servers_list.tpl's contract by propagating S.enabled into the View DTO + tagging disabled <li>s with data-server-skip="1" (the helper's loadTile() short-circuits on that) AND a visible "Disabled" pill so the admin sees the bound-but-disabled relationship without wondering why no hostname appeared. Saves N pointless Actions.ServersHostPlayers round-trips per page-load on installs with disabled servers in groups. 2. Extended the e2e spec with two arms - disabled-server (asserts probe is skipped + the Disabled pill renders) and dangling-membership-row (deletes the bound server via a new e2e SQL-shim that bypasses api_servers_remove's cleanup, asserts the INNER JOIN drops the orphaned row). The latter locks in the worker's documented INNER-JOIN-over-LEFT-JOIN choice operationally, not just structurally. Skipped reviewer findings 2/3 (N+1, missing index - pre-existing), 4 (empty-state inline shape - defensible in cramped per-card body; added an inline rationale comment for future maintainers), 6 (hydration concurrency - pre-existing pattern), 7 (perm boundary - intentional per issue body), and all 8 NITs (cosmetic).
1 parent 3c9b703 commit 051b428

9 files changed

Lines changed: 1642 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

web/includes/View/AdminGroupsListView.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,18 @@ final class AdminGroupsListView extends View
5252
* (`{type, name, access}`) for the server admin groups.
5353
* @param list<array<string,mixed>> $server_list Server group rows
5454
* (`:prefix_groups` WHERE type = 3). Same naming-clash caveat
55-
* as `$server_group_list` above.
55+
* as `$server_group_list` above. Each row carries a `servers`
56+
* key — a `list<array{sid: int, ip: string, port: int, enabled: bool}>`
57+
* of the bound `:prefix_servers` records — that drives the
58+
* per-group `[data-testid="server-tile"]` card stack in
59+
* `page_admin_groups_list.tpl`. The shared hydration helper
60+
* (`web/scripts/server-tile-hydrate.js`) picks up each tile
61+
* and fires `Actions.ServersHostPlayers` to replace the SSR
62+
* `IP:port` fallback with the live hostname. Disabled servers
63+
* stay visible (the bound-but-disabled relationship is the
64+
* useful operator context) but the template gates the
65+
* hydration probe on `enabled` via `data-server-skip="1"`
66+
* (mirror of `page_admin_servers_list.tpl`). #1406.
5667
* @param list<int> $server_counts Per-group server counts, indexed
5768
* parallel to `$server_list`.
5869
* @param list<array{name: string, value: int, label: string}> $all_flags Web-permission flag

web/pages/admin.groups.php

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -137,31 +137,66 @@
137137
// ------------------------------------------------------------------
138138
// Server groups (`:prefix_groups` WHERE type = 3).
139139
//
140-
// #1404 the pre-fix loop also echoed a per-row
141-
// `<script>LoadServerHostPlayersList('<sids>', 'id', 'servers_<gid>')</script>`
142-
// blob meant to async-hydrate the per-group server list into the
143-
// matching `<div id="servers_{gid}">` slot. The helper was deleted
144-
// with `sourcebans.js` at #1123 D1, so every page load raised
145-
// `ReferenceError: LoadServerHostPlayersList is not defined` once
146-
// per server group AND left the slot showing the literal "Servers
147-
// populate via the legacy LoadServerHostPlayersList hook." copy
148-
// admin-facing forever. The echo + the slot + the placeholder copy
149-
// all went together; the `:prefix_servers_groups` lookup that fed
150-
// the SID list went too (nothing else read it). Hydration to per-
151-
// group server cards is the next step — tracked as a follow-up
152-
// ticket; see AGENTS.md "Anti-patterns" for the matching
153-
// `LoadServerHostPlayersList` entry.
140+
// #1404 dropped the pre-fix `<script>LoadServerHostPlayersList(...)`
141+
// echo + the literal "Servers populate via the legacy ... hook."
142+
// placeholder + the `<div id="servers_{gid}">` slot. #1406 is the
143+
// additive replacement: the per-group `servers` array (sid / ip /
144+
// port) we load here drives a vertical stack of
145+
// `[data-testid="server-tile"]` cards in the template, hydrated
146+
// client-side via the shared `web/scripts/server-tile-hydrate.js`
147+
// helper that already powers the public Server List + admin Server
148+
// Management + dashboard widget. The bare `IP:port` per row stays
149+
// as the SSR / cache-cold / no-JS fallback inside
150+
// `[data-testid="server-host"]`. The hydration helper auto-runs on
151+
// first paint for every `[data-server-hydrate="auto"]` container
152+
// and fires `Actions.ServersHostPlayers` per tile, so no new JSON
153+
// action is registered for this surface.
154154
// ------------------------------------------------------------------
155155
$server_group_rows = $GLOBALS['PDO']->query("SELECT * FROM `:prefix_groups` WHERE type = '3'")->resultset();
156156
$server_list = [];
157157
$server_counts = [];
158158
foreach ($server_group_rows as $row) {
159159
$row['gid'] = (int) $row['gid'];
160160

161-
$GLOBALS['PDO']->query("SELECT COUNT(server_id) AS cnt FROM `:prefix_servers_groups` WHERE `group_id` = :gid");
161+
// INNER JOIN against `:prefix_servers` so groups that retain
162+
// dangling `:prefix_servers_groups` rows from a deleted server
163+
// (the schema has no cascade) silently drop those rows from
164+
// the card list — there's nothing useful to render for a sid
165+
// that no longer exists, and the hydration helper would hit
166+
// the "server not found" arm of `api_servers_host_players` on
167+
// every page load. ORDER BY S.sid keeps the render order stable
168+
// across page loads so a refresh doesn't shuffle the cards.
169+
//
170+
// `S.enabled` rides the projection (post-review): disabled
171+
// servers should STILL surface ("this group is bound to N
172+
// servers, here are their addresses" stays useful even when
173+
// some are disabled — silently filtering them out would hide
174+
// the bound-but-disabled relationship from the admin), but
175+
// the template tags each `<li>` with `data-server-skip="1"`
176+
// so `server-tile-hydrate.js` short-circuits before firing
177+
// `Actions.ServersHostPlayers` against a server the panel
178+
// already knows is offline by config. Mirrors the sibling
179+
// contract in `page_admin_servers_list.tpl`.
180+
$GLOBALS['PDO']->query(
181+
"SELECT S.sid, S.ip, S.port, S.enabled
182+
FROM `:prefix_servers_groups` AS SG
183+
INNER JOIN `:prefix_servers` AS S ON S.sid = SG.server_id
184+
WHERE SG.group_id = :gid
185+
ORDER BY S.sid ASC"
186+
);
162187
$GLOBALS['PDO']->bind(':gid', $row['gid']);
163-
$cnt = $GLOBALS['PDO']->single();
164-
$row['server_count'] = (int) $cnt['cnt'];
188+
$serverRows = $GLOBALS['PDO']->resultset();
189+
190+
$row['servers'] = array_map(static fn (array $s): array => [
191+
'sid' => (int) $s['sid'],
192+
'ip' => (string) $s['ip'],
193+
'port' => (int) $s['port'],
194+
// `:prefix_servers.enabled` is `TINYINT NOT NULL DEFAULT '1'`
195+
// — cast to bool here so the template's `{if !$server.enabled}`
196+
// gate doesn't have to know the on-disk shape.
197+
'enabled' => (bool) $s['enabled'],
198+
], $serverRows);
199+
$row['server_count'] = count($row['servers']);
165200

166201
$server_list[] = $row;
167202
$server_counts[] = $row['server_count'];

web/tests/e2e/fixtures/db.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const SET_SETTING_INSIDE_CONTAINER =
3939
'/var/www/html/web/tests/e2e/scripts/set-setting-e2e.php';
4040
const ORPHAN_BAN_AID_INSIDE_CONTAINER =
4141
'/var/www/html/web/tests/e2e/scripts/orphan-ban-aid-e2e.php';
42+
const SEED_SERVER_GROUP_INSIDE_CONTAINER =
43+
'/var/www/html/web/tests/e2e/scripts/seed-server-group-e2e.php';
44+
const DELETE_SERVER_INSIDE_CONTAINER =
45+
'/var/www/html/web/tests/e2e/scripts/delete-server-e2e.php';
4246

4347
/**
4448
* Run the PHP shim that drives `Sbpp\Tests\Fixture` against
@@ -399,6 +403,183 @@ export async function orphanBanAidE2e(bid: number, newAid = 99999): Promise<void
399403
});
400404
}
401405

406+
/**
407+
* Per-server seed row consumed by `seedServerGroupWithServersE2e`.
408+
* RFC 5737 documentation IPs (203.0.113.0/24, 198.51.100.0/24,
409+
* 192.0.2.0/24) are recommended so a real Source server can't ever
410+
* answer the A2S probe; the spec stubs `Actions.ServersHostPlayers`
411+
* via `page.route` anyway, but the IPs show up in the SSR fallback.
412+
*
413+
* `enabled` defaults to `true` (matches the schema's
414+
* `:prefix_servers.enabled TINYINT NOT NULL DEFAULT '1'`). Pass
415+
* `false` to seed a server the admin Server Groups card stack will
416+
* tag with `data-server-skip="1"` + the visible "Disabled" pill so
417+
* `server-tile-hydrate.js` short-circuits the per-tile probe
418+
* (#1406 post-review).
419+
*/
420+
export interface ServerGroupSeedServer {
421+
ip: string;
422+
port: number;
423+
enabled?: boolean;
424+
}
425+
426+
/**
427+
* Shape returned by `seedServerGroupWithServersE2e`. `gid` is the
428+
* server-group's `:prefix_groups.gid`; `sids` mirrors the per-server
429+
* `:prefix_servers.sid` list in insert order so the spec can keyword
430+
* each `page.route` stub by sid. `enabled` flows through verbatim so
431+
* specs covering the disabled-server arm can assert against the
432+
* canonical value the shim wrote without re-deriving it.
433+
*/
434+
export interface ServerGroupSeedResult {
435+
gid: number;
436+
sids: number[];
437+
servers: Array<{ sid: number; ip: string; port: number; enabled: boolean }>;
438+
}
439+
440+
/**
441+
* Seed a `:prefix_groups (type=3)` server group with N bound
442+
* `:prefix_servers` rows wired through `:prefix_servers_groups`.
443+
* Used by the admin Server Groups card-hydration spec (#1406) because
444+
* there is no JSON action that wires a server into a server group's
445+
* membership — the legacy master-detail UI write path is the only
446+
* existing surface, and driving an HTML form post through Playwright
447+
* would couple the spec to the master-detail chrome (unrelated to
448+
* what we're verifying: each per-group card carries the hydration
449+
* testids + the live hostname patches in over the SSR IP:port).
450+
*
451+
* Mirrors the `seedCommsRawE2e` / `seedLostpasswordE2e` shape:
452+
* shells out to a PHP shim that runs against the e2e DB and refuses
453+
* any other.
454+
*/
455+
export async function seedServerGroupWithServersE2e(
456+
groupName: string,
457+
servers: ServerGroupSeedServer[],
458+
): Promise<ServerGroupSeedResult> {
459+
const inContainer = process.env.E2E_IN_CONTAINER === '1';
460+
const cmd = inContainer ? 'php' : 'docker';
461+
const cmdArgs = inContainer
462+
? [SEED_SERVER_GROUP_INSIDE_CONTAINER]
463+
: ['compose', 'exec', '-T', 'web', 'php', SEED_SERVER_GROUP_INSIDE_CONTAINER];
464+
465+
const child = execFile(cmd, cmdArgs, {
466+
maxBuffer: 8 * 1024 * 1024,
467+
cwd: inContainer ? undefined : process.cwd(),
468+
});
469+
470+
let stdout = '';
471+
let stderr = '';
472+
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
473+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
474+
475+
child.stdin?.write(JSON.stringify({
476+
group: { name: groupName },
477+
servers,
478+
}));
479+
child.stdin?.end();
480+
481+
await new Promise<void>((resolve, reject) => {
482+
child.on('error', reject);
483+
child.on('exit', (code) => {
484+
if (code === 0) {
485+
resolve();
486+
return;
487+
}
488+
reject(new Error(
489+
`seed-server-group-e2e.php exited ${code}\n`
490+
+ `stdout:\n${stdout}\nstderr:\n${stderr}`,
491+
));
492+
});
493+
});
494+
495+
const trimmed = stdout.trim();
496+
if (trimmed === '') {
497+
throw new Error(`seed-server-group-e2e.php: empty stdout\nstderr:\n${stderr}`);
498+
}
499+
try {
500+
const parsed = JSON.parse(trimmed) as ServerGroupSeedResult;
501+
if (typeof parsed.gid !== 'number' || !Array.isArray(parsed.sids) || !Array.isArray(parsed.servers)) {
502+
throw new Error('missing gid/sids/servers keys');
503+
}
504+
return parsed;
505+
} catch (err) {
506+
const msg = err instanceof Error ? err.message : String(err);
507+
throw new Error(
508+
`seed-server-group-e2e.php: malformed stdout (${msg})\nstdout:\n${trimmed}\nstderr:\n${stderr}`,
509+
);
510+
}
511+
}
512+
513+
/**
514+
* Delete a `:prefix_servers` row by `sid` via the dedicated e2e shim
515+
* that **bypasses `api_servers_remove`**'s cleanup cascade. The
516+
* dispatcher action runs a paired
517+
* `DELETE FROM :prefix_servers_groups WHERE server_id = ?` (plus
518+
* sibling cascades on `:prefix_admins_servers_groups`, etc.) which
519+
* defeats the test purpose of the dangling-membership-row spec arm
520+
* in `admin-groups-server-cards-hydration.spec.ts`.
521+
*
522+
* The whole point of that spec arm is to prove the admin Server
523+
* Groups page's INNER JOIN (added in #1406) silently drops orphaned
524+
* `:prefix_servers_groups` rows; the orphan only exists if the
525+
* server delete LEAVES the membership row in place. The raw SQL
526+
* shim is the narrow shape that produces the test condition.
527+
*
528+
* Idempotent: deleting an already-deleted sid is a no-op and the
529+
* shim returns `{deleted: 0}` so the caller can sanity-check.
530+
*/
531+
export async function deleteServerE2e(sid: number): Promise<{ sid: number; deleted: number }> {
532+
const inContainer = process.env.E2E_IN_CONTAINER === '1';
533+
const cmd = inContainer ? 'php' : 'docker';
534+
const cmdArgs = inContainer
535+
? [DELETE_SERVER_INSIDE_CONTAINER]
536+
: ['compose', 'exec', '-T', 'web', 'php', DELETE_SERVER_INSIDE_CONTAINER];
537+
538+
const child = execFile(cmd, cmdArgs, {
539+
maxBuffer: 8 * 1024 * 1024,
540+
cwd: inContainer ? undefined : process.cwd(),
541+
});
542+
543+
let stdout = '';
544+
let stderr = '';
545+
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
546+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
547+
548+
child.stdin?.write(JSON.stringify({ sid }));
549+
child.stdin?.end();
550+
551+
await new Promise<void>((resolve, reject) => {
552+
child.on('error', reject);
553+
child.on('exit', (code) => {
554+
if (code === 0) {
555+
resolve();
556+
return;
557+
}
558+
reject(new Error(
559+
`delete-server-e2e.php exited ${code}\n`
560+
+ `stdout:\n${stdout}\nstderr:\n${stderr}`,
561+
));
562+
});
563+
});
564+
565+
const trimmed = stdout.trim();
566+
if (trimmed === '') {
567+
throw new Error(`delete-server-e2e.php: empty stdout\nstderr:\n${stderr}`);
568+
}
569+
try {
570+
const parsed = JSON.parse(trimmed) as { sid: number; deleted: number };
571+
if (typeof parsed.sid !== 'number' || typeof parsed.deleted !== 'number') {
572+
throw new Error('missing sid/deleted keys');
573+
}
574+
return parsed;
575+
} catch (err) {
576+
const msg = err instanceof Error ? err.message : String(err);
577+
throw new Error(
578+
`delete-server-e2e.php: malformed stdout (${msg})\nstdout:\n${trimmed}\nstderr:\n${stderr}`,
579+
);
580+
}
581+
}
582+
402583
async function runAnnouncementsHelper(
403584
stdin: string | null,
404585
extraArgs: string[],
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
// SourceBans++ (c) 2014-2026 SourceBans++ Dev Team
3+
// Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0.
4+
// See LICENSE.md for the full license text and THIRD-PARTY-NOTICES.txt for attributions.
5+
/**
6+
* E2E server-deletion shim.
7+
*
8+
* Deletes a single `:prefix_servers` row by `sid`. **Deliberately
9+
* bypasses `api_servers_remove`** because that action runs a
10+
* cleanup cascade (`DELETE FROM :prefix_servers_groups WHERE
11+
* server_id = ?`, `DELETE FROM :prefix_admins_servers_groups WHERE
12+
* server_id = ?`, etc.) that defeats the test purpose of the
13+
* dangling-membership-row spec arm in
14+
* `admin-groups-server-cards-hydration.spec.ts`.
15+
*
16+
* The whole point of that spec arm is to prove the admin Server
17+
* Groups page's INNER JOIN (added in #1406) silently drops orphaned
18+
* `:prefix_servers_groups` rows — which means the test setup needs
19+
* to LEAVE the orphaned `:prefix_servers_groups` row in place AFTER
20+
* the server delete lands. The dispatcher's cleanup cascade would
21+
* make the orphan impossible to produce.
22+
*
23+
* Same e2e-only guardrails as `seed-server-group-e2e.php` /
24+
* `seed-comms-e2e.php` / `set-setting-e2e.php`: refuses any DB other
25+
* than the e2e schema (default `sourcebans_e2e`).
26+
*
27+
* Idempotent: deleting an already-deleted sid is a no-op (the
28+
* `DELETE` matches zero rows). The shim exits 0 either way and
29+
* surfaces the actual rowcount on stdout so the caller can sanity-
30+
* check whether the row existed when expected.
31+
*
32+
* Usage (inside the web container):
33+
*
34+
* echo '{"sid":7}' | php delete-server-e2e.php
35+
*
36+
* Output on stdout (single JSON line):
37+
*
38+
* {"sid":7,"deleted":1}
39+
*/
40+
41+
declare(strict_types=1);
42+
43+
if (PHP_SAPI !== 'cli') {
44+
fwrite(STDERR, "delete-server-e2e.php must run on the CLI.\n");
45+
exit(2);
46+
}
47+
48+
if (!getenv('DB_NAME')) {
49+
putenv('DB_NAME=sourcebans_e2e');
50+
$_ENV['DB_NAME'] = 'sourcebans_e2e';
51+
$_SERVER['DB_NAME'] = 'sourcebans_e2e';
52+
}
53+
54+
if (getenv('DB_NAME') === 'sourcebans_test' || getenv('DB_NAME') === 'sourcebans') {
55+
fwrite(STDERR, "refusing to delete server against DB_NAME=" . getenv('DB_NAME')
56+
. ": this script must target a dedicated e2e DB (default sourcebans_e2e).\n");
57+
exit(2);
58+
}
59+
60+
require __DIR__ . '/../../bootstrap.php';
61+
62+
if (!isset($GLOBALS['PDO'])) {
63+
$GLOBALS['PDO'] = new \Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET);
64+
}
65+
66+
$payload = stream_get_contents(STDIN);
67+
if ($payload === false || trim($payload) === '') {
68+
fwrite(STDERR, "delete-server-e2e.php: empty stdin payload.\n");
69+
exit(2);
70+
}
71+
72+
$decoded = json_decode($payload, true);
73+
if (!is_array($decoded)) {
74+
fwrite(STDERR, "delete-server-e2e.php: stdin is not a JSON object.\n");
75+
exit(2);
76+
}
77+
78+
$sid = (int)($decoded['sid'] ?? 0);
79+
if ($sid <= 0) {
80+
fwrite(STDERR, "delete-server-e2e.php: missing or invalid `sid` in payload.\n");
81+
exit(2);
82+
}
83+
84+
// Raw DELETE — NO cascade. The whole point of this shim is to
85+
// produce a `:prefix_servers_groups` row that points at a sid which
86+
// no longer exists in `:prefix_servers`, so the panel handler's
87+
// INNER JOIN can be exercised against an actual orphan. Using
88+
// `api_servers_remove` here would silently clean up the
89+
// membership row in the same transaction and the spec arm would
90+
// have nothing to test.
91+
$GLOBALS['PDO']->query("DELETE FROM `:prefix_servers` WHERE sid = ?");
92+
$GLOBALS['PDO']->execute([$sid]);
93+
94+
$result = [
95+
'sid' => $sid,
96+
'deleted' => $GLOBALS['PDO']->rowCount(),
97+
];
98+
99+
fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES) . "\n");

0 commit comments

Comments
 (0)