Skip to content

Commit e7d5ef0

Browse files
committed
feat: bridge smoke test + integration test suite (SDLC gap fix)
1 parent f859672 commit e7d5ef0

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { addonOpsActivitySummary } from "../src/duneDb.js";
4+
import { addonOpsResourcesSummary } from "../src/duneDb.js";
5+
import { addonOpsCombatDeaths } from "../src/duneDb.js";
6+
import { detectTransitions } from "../src/deathPoller.js";
7+
8+
// ─── Helper: create a real DB connection for integration tests ───
9+
let db = null;
10+
11+
async function getDb() {
12+
if (db) return db;
13+
try {
14+
const { createDb } = await import("../src/db.js");
15+
db = createDb({});
16+
return db;
17+
} catch (e) {
18+
if (e.code === "ERR_MODULE_NOT_FOUND") {
19+
console.log("Skipping integration tests — pg module not installed (npm ci)");
20+
return null;
21+
}
22+
throw e;
23+
}
24+
}
25+
26+
// ─── ops.activity.summary integration ───
27+
28+
test("ops.activity.summary — live DB — returns valid structure", { skip: !process.env.RUN_INTEGRATION }, async () => {
29+
const database = await getDb();
30+
if (!database) return;
31+
32+
const result = await addonOpsActivitySummary(database);
33+
assert.equal(typeof result.totalPlayers, "number");
34+
assert.equal(typeof result.onlinePlayers, "number");
35+
assert.equal(typeof result.playersDead, "number");
36+
assert.equal(typeof result.activeLast1h, "number");
37+
assert.equal(typeof result.activeLast24h, "number");
38+
assert.ok(Array.isArray(result.guildActivity));
39+
assert.ok(Array.isArray(result.factionActivity));
40+
assert.ok(Array.isArray(result.mapActivity));
41+
42+
// All arrays must have consistent object shapes
43+
for (const g of result.guildActivity) {
44+
assert.equal(typeof g.guild, "string");
45+
assert.equal(typeof g.members, "number");
46+
assert.equal(typeof g.online, "number");
47+
}
48+
for (const f of result.factionActivity) {
49+
assert.equal(typeof f.faction, "string");
50+
assert.equal(typeof f.members, "number");
51+
assert.equal(typeof f.online, "number");
52+
}
53+
});
54+
55+
test("ops.activity.summary — live DB — playersDead non-negative", { skip: !process.env.RUN_INTEGRATION }, async () => {
56+
const database = await getDb();
57+
if (!database) return;
58+
59+
const result = await addonOpsActivitySummary(database);
60+
assert.ok(result.playersDead >= 0);
61+
assert.ok(result.totalPlayers >= result.playersDead);
62+
assert.ok(result.totalPlayers >= result.onlinePlayers);
63+
});
64+
65+
// ─── ops.resources.summary integration ───
66+
67+
test("ops.resources.summary — live DB — returns valid structure", { skip: !process.env.RUN_INTEGRATION }, async () => {
68+
const database = await getDb();
69+
if (!database) return;
70+
71+
const result = await addonOpsResourcesSummary(database);
72+
assert.equal(typeof result.totalFields, "number");
73+
assert.equal(typeof result.totalValueRemaining, "number");
74+
assert.ok(Array.isArray(result.resourcesByMap));
75+
assert.ok(Array.isArray(result.spiceFieldsBySize));
76+
77+
for (const m of result.resourcesByMap) {
78+
assert.equal(typeof m.map, "string");
79+
assert.equal(typeof m.fields, "number");
80+
assert.equal(typeof m.totalValue, "number");
81+
}
82+
for (const s of result.spiceFieldsBySize) {
83+
assert.equal(typeof s.map, "string");
84+
assert.equal(typeof s.size, "string");
85+
assert.equal(typeof s.active_fields, "number");
86+
assert.equal(typeof s.total_value, "number");
87+
assert.equal(typeof s.currently_active, "number");
88+
assert.equal(typeof s.max_active, "number");
89+
}
90+
});
91+
92+
test("ops.resources.summary — live DB — spice only (field_kind_id=1)", { skip: !process.env.RUN_INTEGRATION }, async () => {
93+
const database = await getDb();
94+
if (!database) return;
95+
96+
const result = await addonOpsResourcesSummary(database);
97+
assert.ok(result.totalFields >= 0);
98+
assert.ok(result.totalValueRemaining >= 0);
99+
// Spice fields should be a subset of total with field_kind_id=1 filter
100+
});
101+
102+
// ─── ops.combat.deaths integration ───
103+
104+
test("ops.combat.deaths — live DB — returns valid structure", { skip: !process.env.RUN_INTEGRATION }, async () => {
105+
const database = await getDb();
106+
if (!database) return;
107+
108+
const result = await addonOpsCombatDeaths(database);
109+
assert.equal(typeof result.totalDeaths, "number");
110+
assert.equal(typeof result.pvpDeaths, "number");
111+
assert.equal(typeof result.pveDeaths, "number");
112+
assert.ok(Array.isArray(result.deathsByCause));
113+
assert.equal(result.kdRatio, null, "kdRatio must be null — no kill count");
114+
assert.equal(result.topHostileNpcs.length, 0, "NPC kills not tracked locally");
115+
assert.equal(result.deathsByMap.length, 0, "death map not tracked locally");
116+
117+
for (const d of result.deathsByCause) {
118+
assert.equal(typeof d.cause, "string");
119+
assert.equal(typeof d.count, "number");
120+
assert.ok(d.count >= 0);
121+
}
122+
});
123+
124+
// ─── Death poller detectTransitions unit ───
125+
126+
test("detectTransitions — empty snapshot = zero deaths", () => {
127+
const previous = new Map();
128+
const current = new Map([
129+
["1", "Dead"],
130+
["2", "DeadBySandworm"],
131+
["3", "DeadByCoriolis"]
132+
]);
133+
const deaths = detectTransitions(previous, current);
134+
assert.equal(deaths.length, 0, "no previous snapshot, should not count existing Dead* players");
135+
136+
const currentAllDead = new Map([["1", "Dead"], ["2", "Dead"]]);
137+
const deaths2 = detectTransitions(previous, currentAllDead);
138+
assert.equal(deaths2.length, 0, "same — all dead, no snapshot");
139+
});
140+
141+
test("detectTransitions — Alive→Dead transition detected", () => {
142+
const previous = new Map([["1", "Alive"], ["2", "Alive"]]);
143+
const current = new Map([["1", "Dead"], ["2", "Alive"]]);
144+
const deaths = detectTransitions(previous, current);
145+
assert.equal(deaths.length, 1);
146+
assert.equal(deaths[0].player_controller_id, "1");
147+
assert.equal(deaths[0].death_cause, "Dead");
148+
});
149+
150+
test("detectTransitions — multiple death causes", () => {
151+
const previous = new Map([["1", "Alive"], ["2", "Alive"], ["3", "Alive"]]);
152+
const current = new Map([["1", "DeadBySandworm"], ["2", "DeadByCoriolis"], ["3", "Alive"]]);
153+
const deaths = detectTransitions(previous, current);
154+
assert.equal(deaths.length, 2);
155+
assert.equal(deaths[0].death_cause, "DeadBySandworm");
156+
assert.equal(deaths[1].death_cause, "DeadByCoriolis");
157+
});
158+
159+
test("detectTransitions — respawn (Dead→Alive) is not a new death", () => {
160+
const previous = new Map([["1", "Dead"], ["2", "Alive"]]);
161+
const current = new Map([["1", "Alive"], ["2", "Alive"]]);
162+
const deaths = detectTransitions(previous, current);
163+
assert.equal(deaths.length, 0, "player 1 respawned — should not count as new death");
164+
});
165+
166+
test("detectTransitions — Dead staying Dead is not re-counted", () => {
167+
const previous = new Map([["1", "Dead"], ["2", "DeadByCoriolis"]]);
168+
const current = new Map([["1", "Dead"], ["2", "DeadByCoriolis"]]);
169+
const deaths = detectTransitions(previous, current);
170+
assert.equal(deaths.length, 0, "no state change — no new deaths");
171+
});
172+
173+
test("detectTransitions — new player appears as Alive, dies", () => {
174+
const previous = new Map([["1", "Alive"]]);
175+
const current = new Map([["1", "Dead"], ["2", "Alive"]]);
176+
const deaths = detectTransitions(previous, current);
177+
assert.equal(deaths.length, 1);
178+
assert.equal(deaths[0].player_controller_id, "1");
179+
// Player 2 is new (not in previous) and Alive — no death counted
180+
});

tests/bridge-smoke-test.sh

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env bash
2+
# bridge-smoke-test.sh — Post-deploy validation for all addon bridge actions.
3+
# Runs after Console restart to catch schema mismatches and regressions
4+
# before they reach upstream review.
5+
#
6+
# Usage: bash tests/bridge-smoke-test.sh [--skip-auth]
7+
8+
set -euo pipefail
9+
10+
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
11+
PASSED=0; FAILED=0; SKIPPED=0
12+
f() { echo -e " ${RED}FAIL:${NC} $*"; FAILED=$((FAILED+1)); }
13+
p() { echo -e " ${GREEN}PASS:${NC} $*"; PASSED=$((PASSED+1)); }
14+
w() { echo -e " ${YELLOW}SKIP:${NC} $*"; SKIPPED=$((SKIPPED+1)); }
15+
16+
CONSOLE_PORT="${CONSOLE_PORT:-8088}"
17+
BASE_URL="http://127.0.0.1:${CONSOLE_PORT}"
18+
CURL="curl -fsS --connect-timeout 3 --max-time 10"
19+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
20+
cd "$REPO_ROOT"
21+
22+
echo "=== Bridge Smoke Test ==="
23+
echo "Target: $BASE_URL"
24+
echo
25+
26+
# ─── Auth setup ───
27+
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
28+
if [ -z "$ADMIN_PASSWORD" ] && [ -s runtime/secrets/admin-web-password.txt ]; then
29+
ADMIN_PASSWORD="$(tr -d '\r\n' < runtime/secrets/admin-web-password.txt)"
30+
fi
31+
32+
SKIP_AUTH=false
33+
if [ "${1:-}" = "--skip-auth" ]; then SKIP_AUTH=true; fi
34+
35+
SESSION=""; CSRF=""
36+
37+
if ! $SKIP_AUTH && [ -n "$ADMIN_PASSWORD" ]; then
38+
LOGIN_RESP="$($CURL -sS -X POST "$BASE_URL/api/login" \
39+
-H "Content-Type: application/json" \
40+
-d "{\"password\":\"$ADMIN_PASSWORD\"}" 2>/dev/null)" || true
41+
if echo "$LOGIN_RESP" | grep -q '"ok":true'; then
42+
SESSION="$(echo "$LOGIN_RESP" | grep -o 'asc_session=[^";]*' | head -1 || true)"
43+
CSRF="$(echo "$LOGIN_RESP" | grep -o '"csrf":"[^"]*"' | head -1 | sed 's/"csrf":"//;s/"//' || true)"
44+
p "authenticated to Console"
45+
else
46+
w "auth failed — bridge tests will use unauthenticated path"
47+
fi
48+
else
49+
w "no credentials — bridge tests will use unauthenticated path"
50+
fi
51+
52+
if [ -z "$SESSION" ]; then SKIP_AUTH=true; fi
53+
54+
# ─── Auth headers ───
55+
AUTH_HEADERS=()
56+
if [ -n "$SESSION" ]; then AUTH_HEADERS+=(-H "Cookie: $SESSION"); fi
57+
if [ -n "$CSRF" ]; then AUTH_HEADERS+=(-H "X-CSRF-Token: $CSRF"); fi
58+
59+
# ─── Helper: call bridge action ───
60+
bridge_call() {
61+
local action="$1" label="$2" required_fields="$3"
62+
local resp code
63+
64+
resp="$($CURL -sS -X POST "$BASE_URL/api/addons/installed/dune-ops-observability/bridge" \
65+
"${AUTH_HEADERS[@]}" \
66+
-H "Content-Type: application/json" \
67+
-d "{\"action\":\"$action\"}" 2>/dev/null)" || true
68+
69+
if [ -z "$resp" ]; then
70+
f "$label — no response (401 auth / 400 unsupported action)"
71+
return
72+
fi
73+
74+
if echo "$resp" | grep -q '"error"'; then
75+
w "$label — error: $(echo "$resp" | grep -o '"error":"[^"]*"' | head -1)"
76+
return
77+
fi
78+
79+
if echo "$resp" | grep -q '"ok":true'; then
80+
local missing=0
81+
for field in $required_fields; do
82+
if ! echo "$resp" | grep -q "\"$field\""; then
83+
f "$label — missing required field: $field"
84+
missing=1
85+
break
86+
fi
87+
done
88+
if [ "$missing" -eq 0 ]; then
89+
p "$label — response valid, all required fields present"
90+
fi
91+
else
92+
f "$label — unexpected response: $(echo "$resp" | head -c 200)"
93+
fi
94+
}
95+
96+
# ─── Test each bridge action ───
97+
98+
echo "--- Core bridge actions ---"
99+
100+
# ops.health.* (v0.3.0 — must always work)
101+
bridge_call "ops.health.summary" "ops.health.summary" "ok result"
102+
bridge_call "ops.health.players" "ops.health.players" "ok result total"
103+
bridge_call "ops.health.farms" "ops.health.farms" "ok result"
104+
bridge_call "ops.health.summary.v2" "ops.health.summary.v2" "ok result"
105+
106+
# leadership.players.list (must always work)
107+
bridge_call "leadership.players.list" "leadership.players.list" "ok result capabilities"
108+
109+
echo "--- v0.4.0 bridge actions ---"
110+
111+
# ops.activity.summary (new in PR #68)
112+
bridge_call "ops.activity.summary" "ops.activity.summary" "ok result totalPlayers onlinePlayers playersDead"
113+
114+
# ops.resources.summary (new in PR #68)
115+
bridge_call "ops.resources.summary" "ops.resources.summary" "ok result totalFields spiceFieldsBySize"
116+
117+
# ops.combat.deaths (new in PR #68)
118+
bridge_call "ops.combat.deaths" "ops.combat.deaths" "ok result totalDeaths deathsByCause"
119+
120+
# ─── Database read bridge ───
121+
echo "--- Database bridge ---"
122+
bridge_call "database.query" "database.query (read-only)" "ok result" || {
123+
# database.query requires database:read permission — may need explicit approval
124+
w "database.query requires database:read permission — verify addon permissions"
125+
}
126+
127+
# ─── Unsupported actions (should fail cleanly) ───
128+
echo "--- Error handling ---"
129+
130+
if ! $SKIP_AUTH; then
131+
UNSUPPORTED="$($CURL -sS -X POST "$BASE_URL/api/addons/installed/dune-ops-observability/bridge" \
132+
"${AUTH_HEADERS[@]}" \
133+
-H "Content-Type: application/json" \
134+
-d '{"action":"nonexistent.action"}' 2>/dev/null)" || true
135+
if echo "$UNSUPPORTED" | grep -q '"Unsupported addon action"'; then
136+
p "unsupported action returns proper error"
137+
else
138+
w "unsupported action response: $(echo "$UNSUPPORTED" | head -c 100)"
139+
fi
140+
fi
141+
142+
# ─── Summary ───
143+
echo; echo "========================================"
144+
echo -e "${GREEN}Passed: $PASSED${NC} ${RED}Failed: $FAILED${NC} ${YELLOW}Skipped: $SKIPPED${NC}"
145+
echo "========================================"
146+
[ "$FAILED" -gt 0 ] && { echo "Bridge smoke test FAILED — fix before upstream PR."; exit 1; }
147+
echo -e "${GREEN}Bridge smoke test PASSED.${NC}"
148+
exit 0

0 commit comments

Comments
 (0)