Files
OmniRoute/tests/unit/gamification/events.test.ts
Paco Cartones 16c68bad49 feat(gamification): pay the documented streak and badge XP rewards (#12522)
`XP_REWARDS` documented `streak_bonus` and `badge_unlock` and the pipeline paid neither — closing that gap is right. The idempotency design carries it: `advanceStreak()` reporting `extended` only on the call that moves the record to today is what keeps a same-day repeat from paying twice.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the rest of this batch — zero conflicts between the 19 PRs.

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, all within the frozen baseline); `check:changelog-integrity` OK
- complexity 2802 / baseline 3218 and cognitive-complexity 1267 / baseline 1437 — both under baseline
- 226 of 228 focused assertions green across the batch's 23 test files. The 2 remaining belong to #12551, which is held separately.

Two batch-owned defects were found and fixed in flight, both pure base drift: `173_xp_action_counts.sql` collided with `173_call_logs_video_content_removed.sql` (renumbered to 176 on #12651 — it aborted every DB open, which is what 53 of the first run's failures were), and the feature-flag catalog was missing the `SERVER_OWNED_TOOL_LOOP_ENABLED` row the base gained after #12552 was written.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, which this batch does not touch).

Thanks @pacocartones — the `file:line` citations and the explicit out-of-scope notes on every one of these made a 19-PR batch reviewable in one pass.
2026-09-11 17:42:32 -03:00

129 lines
4.9 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
import { XP_REWARDS } from "../../../src/lib/gamification/xp";
import { getDbInstance } from "../../../src/lib/db/core";
describe("Gamification Events", () => {
it("does not throw for valid event", async () => {
await assert.doesNotReject(emitGamificationEvent({ apiKeyId: "test-user", action: "request" }));
});
it("does not throw for missing apiKeyId", async () => {
await assert.doesNotReject(emitGamificationEvent({ apiKeyId: "", action: "request" }));
});
it("does not throw for unknown action", async () => {
await assert.doesNotReject(
emitGamificationEvent({ apiKeyId: "test-user", action: "unknown" as any })
);
});
it("checkActionCountBadges counts actions correctly via SQL", async () => {
// Verifies the SELECT fix — before fix, missing SELECT caused silent SQL error
const db = getDbInstance();
const testKey = `test-badge-${Date.now()}`;
for (let i = 0; i < 5; i++) {
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
testKey,
"request",
1
);
}
// Verify the SELECT query works (was broken before fix)
const row = db
.prepare(
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
)
.get(testKey, "request") as { count: number };
assert.equal(row.count, 5);
// Cleanup
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
});
// #2403: the per-key rate limit (1000 XP/min) documented for the anti-cheat layer must
// actually gate the award path. Each case seeds xp_audit_log directly so the window state
// is deterministic, then emits a 1 XP "request" event.
describe("anti-cheat gate on the award path", () => {
function seedXp(apiKeyId: string, xp: number, createdAtModifier?: string): void {
const db = getDbInstance();
if (createdAtModifier) {
db.prepare(
"INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', ?))"
).run(apiKeyId, "seed", xp, createdAtModifier);
} else {
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
apiKeyId,
"seed",
xp
);
}
}
function countRequestRows(apiKeyId: string): number {
const row = getDbInstance()
.prepare(
"SELECT COUNT(*) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = 'request'"
)
.get(apiKeyId) as { count: number };
return row.count;
}
function leaderboardScore(apiKeyId: string): number | undefined {
const row = getDbInstance()
.prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = 'global'")
.get(apiKeyId) as { score: number } | undefined;
return row?.score;
}
function cleanup(apiKeyId: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId);
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId);
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId);
}
it("skips the award once the key has exhausted 1000 XP inside the last minute", async () => {
const key = `rate-limited-${Date.now()}`;
seedXp(key, 1000);
await assert.doesNotReject(emitGamificationEvent({ apiKeyId: key, action: "request" }));
assert.equal(countRequestRows(key), 0, "over-limit award must not be persisted");
assert.equal(
leaderboardScore(key),
undefined,
"over-limit award must not reach the leaderboard"
);
cleanup(key);
});
it("applies the award when the window total stays at or below the limit", async () => {
const key = `under-limit-${Date.now()}`;
seedXp(key, 999); // 999 + 1 == 1000, which is allowed (limit is exclusive of the cap)
await emitGamificationEvent({ apiKeyId: key, action: "request" });
assert.equal(countRequestRows(key), 1);
// The very first request also unlocks the "first-token" badge, and badge unlocks now
// pay XP_REWARDS.badge_unlock through the same leaderboard path. The gate only governs
// the action award, so the score is the 1 XP action plus the badge bonus.
assert.equal(leaderboardScore(key), 1 + XP_REWARDS.badge_unlock);
cleanup(key);
});
it("ignores XP that was earned before the one-minute window", async () => {
const key = `stale-window-${Date.now()}`;
seedXp(key, 1000, "-2 minutes");
await emitGamificationEvent({ apiKeyId: key, action: "request" });
assert.equal(countRequestRows(key), 1, "stale XP must not block a fresh award");
cleanup(key);
});
});
});