mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
`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.
129 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|