mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
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.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **feat(gamification): pay the documented `streak_bonus` and `badge_unlock` XP rewards.** `XP_REWARDS` listed both rewards but the award pipeline never paid them: the private reward table in `events.ts` omitted them, `updateStreak()` did not report when a streak extended, and badge unlocks carried no XP. Every request that extends a daily streak now pays `streak_bonus × streak length` once per UTC day (guarded by a same-day `xp_audit_log` check), and every badge unlocked through the pipeline pays `badge_unlock` once per badge (guarded by the `user_badges` primary key; `unlockBadge()` now reports whether it inserted). Bonus XP flows through the same `addXp` + level sync + global/weekly/monthly leaderboard path as action XP, so level-ups and rankings include it. The Radar supporter recognition unlock stays XP-free. (#12522 — thanks @pacocartones)
|
||||
@@ -222,10 +222,17 @@ export function updateLevel(apiKeyId: string, level: number): void {
|
||||
|
||||
// ──────────────── Badges ────────────────
|
||||
|
||||
export function unlockBadge(apiKeyId: string, badgeId: string): void {
|
||||
db()
|
||||
/**
|
||||
* Award a badge to an API key. Idempotent on the `(api_key_id, badge_id)` primary key.
|
||||
*
|
||||
* @returns `true` when this call inserted the badge, `false` when it was already earned.
|
||||
* Callers that pay the `badge_unlock` XP reward key off this so a badge is paid once.
|
||||
*/
|
||||
export function unlockBadge(apiKeyId: string, badgeId: string): boolean {
|
||||
const result = db()
|
||||
.prepare(`INSERT OR IGNORE INTO user_badges (api_key_id, badge_id) VALUES (?, ?)`)
|
||||
.run(apiKeyId, badgeId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +250,24 @@ export function hasBadge(apiKeyId: string, badgeId: string): boolean {
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `xp_audit_log` already holds an entry for this action on the current UTC day.
|
||||
*
|
||||
* `created_at` is written by the table default `datetime('now')` as
|
||||
* `"YYYY-MM-DD HH:MM:SS"` (UTC), so a lexical compare against `date('now')` selects
|
||||
* today's rows. Used as the once-per-day guard for daily rewards such as `streak_bonus`.
|
||||
*/
|
||||
export function hasXpActionToday(apiKeyId: string, action: string): boolean {
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM xp_audit_log
|
||||
WHERE api_key_id = ? AND action = ? AND created_at >= date('now')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(apiKeyId, action);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function getBadges(apiKeyId: string): UserBadge[] {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
import { calculateLevel, XP_REWARDS } from "./xp";
|
||||
|
||||
const log = logger("GAMIFICATION");
|
||||
|
||||
@@ -57,23 +58,19 @@ export async function emitGamificationEvent(params: {
|
||||
const { addXp } = await import("../db/gamification");
|
||||
addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined);
|
||||
|
||||
// Update level
|
||||
const { getXp, updateLevel } = await import("../db/gamification");
|
||||
const xp = getXp(apiKeyId);
|
||||
if (xp) {
|
||||
const { calculateLevel } = await import("./xp");
|
||||
const newLevel = calculateLevel(xp.totalXp);
|
||||
if (newLevel !== xp.currentLevel) {
|
||||
updateLevel(apiKeyId, newLevel);
|
||||
log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel });
|
||||
}
|
||||
}
|
||||
await syncLevel(apiKeyId);
|
||||
}
|
||||
|
||||
// 2. Update streak
|
||||
if (action === "request") {
|
||||
const { updateStreak } = await import("./streaks");
|
||||
const streak = await updateStreak(apiKeyId);
|
||||
const { advanceStreak } = await import("./streaks");
|
||||
const { currentStreak: streak, extended } = await advanceStreak(apiKeyId);
|
||||
|
||||
// Pay the documented streak_bonus (XP_REWARDS: per consecutive streak day, multiplied
|
||||
// by streak length) on the one request per UTC day that extends the streak.
|
||||
if (extended) {
|
||||
await awardStreakBonus(apiKeyId, streak);
|
||||
}
|
||||
|
||||
// Check streak badges
|
||||
if (streak >= 365) {
|
||||
@@ -112,6 +109,54 @@ export async function emitGamificationEvent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the level from total XP and persist it when it changed.
|
||||
* Runs after every award so bonus XP (streaks, badges) also counts toward level-ups.
|
||||
*/
|
||||
async function syncLevel(apiKeyId: string): Promise<void> {
|
||||
const { getXp, updateLevel } = await import("../db/gamification");
|
||||
const xp = getXp(apiKeyId);
|
||||
if (!xp) return;
|
||||
const newLevel = calculateLevel(xp.totalXp);
|
||||
if (newLevel !== xp.currentLevel) {
|
||||
updateLevel(apiKeyId, newLevel);
|
||||
log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Award a bonus reward (`streak_bonus`, `badge_unlock`) through the same path as action XP:
|
||||
* `xp_audit_log` + `user_levels` via addXp, level sync, and the global/weekly/monthly
|
||||
* leaderboard scopes. Idempotency is the caller's responsibility.
|
||||
*/
|
||||
async function awardBonusXp(
|
||||
apiKeyId: string,
|
||||
action: "streak_bonus" | "badge_unlock",
|
||||
amount: number,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const { addXp } = await import("../db/gamification");
|
||||
addXp(apiKeyId, action, amount, JSON.stringify(metadata));
|
||||
await syncLevel(apiKeyId);
|
||||
|
||||
const { updateScore } = await import("./leaderboard");
|
||||
await updateScore(apiKeyId, "global", amount);
|
||||
await updateScore(apiKeyId, "weekly", amount);
|
||||
await updateScore(apiKeyId, "monthly", amount);
|
||||
log.info("events.bonus_awarded", { apiKeyId, action, amount, ...metadata });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay `streak_bonus × streak` once per UTC day. The `xp_audit_log` same-day check and the
|
||||
* insert run synchronously with no await in between, so two requests racing at the day
|
||||
* boundary cannot both pay.
|
||||
*/
|
||||
async function awardStreakBonus(apiKeyId: string, streak: number): Promise<void> {
|
||||
const { hasXpActionToday } = await import("../db/gamification");
|
||||
if (hasXpActionToday(apiKeyId, "streak_bonus")) return;
|
||||
await awardBonusXp(apiKeyId, "streak_bonus", XP_REWARDS.streak_bonus * streak, { streak });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP amount for an action.
|
||||
*/
|
||||
@@ -130,20 +175,28 @@ function getXpForAction(action: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and unlock a specific badge.
|
||||
* Check and unlock a specific badge, paying the documented `badge_unlock` XP once per badge.
|
||||
*
|
||||
* @param rewardable - `false` for recognition-only unlocks (Radar supporter): the caller
|
||||
* supplies a one-way identity, so the unlock neither earns XP nor logs the identity.
|
||||
*/
|
||||
async function checkAndUnlockBadge(
|
||||
apiKeyId: string,
|
||||
badgeId: string,
|
||||
logIdentity = true
|
||||
rewardable = true
|
||||
): Promise<void> {
|
||||
const { unlockBadge, hasBadge } = await import("../db/gamification");
|
||||
// #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is
|
||||
// empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on
|
||||
// every request.
|
||||
if (!hasBadge(apiKeyId, badgeId)) {
|
||||
unlockBadge(apiKeyId, badgeId);
|
||||
log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId });
|
||||
// unlockBadge is INSERT OR IGNORE on the (api_key_id, badge_id) primary key; only the call
|
||||
// that actually inserts the row pays, so concurrent unlocks cannot double-pay.
|
||||
const inserted = unlockBadge(apiKeyId, badgeId);
|
||||
log.info("events.badge_unlocked", rewardable ? { apiKeyId, badgeId } : { badgeId });
|
||||
if (inserted && rewardable) {
|
||||
await awardBonusXp(apiKeyId, "badge_unlock", XP_REWARDS.badge_unlock, { badgeId });
|
||||
}
|
||||
|
||||
// Look up badge details from badge_definitions
|
||||
const { getDbInstance } = await import("../db/core");
|
||||
|
||||
@@ -157,7 +157,39 @@ export async function getAggregateStreak(): Promise<
|
||||
* console.log(count); // 8
|
||||
*/
|
||||
export async function updateStreak(apiKeyId: string): Promise<number> {
|
||||
if (isBuildPhase || isCloud) return 0;
|
||||
const { currentStreak } = await advanceStreak(apiKeyId);
|
||||
return currentStreak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link advanceStreak}.
|
||||
*/
|
||||
export interface StreakAdvance {
|
||||
/** Current consecutive active days after this call */
|
||||
currentStreak: number;
|
||||
/**
|
||||
* `true` only on the call that extended the streak onto a new consecutive day
|
||||
* (yesterday was active, today was not yet counted). `false` when today was
|
||||
* already counted, when a new streak starts at 1, or when streaks are disabled.
|
||||
*/
|
||||
extended: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link updateStreak}, but also reports whether this call extended the
|
||||
* streak onto a new consecutive day. The award pipeline uses `extended` to pay
|
||||
* the `streak_bonus` reward once per UTC day; repeated requests on the same day
|
||||
* see `extended: false` because the record already carries today's date.
|
||||
*
|
||||
* @param apiKeyId - The API key identifier
|
||||
* @returns The new streak count and whether it just extended
|
||||
*
|
||||
* @example
|
||||
* const { currentStreak, extended } = await advanceStreak("key_abc123");
|
||||
* if (extended) console.log(`day ${currentStreak} of the streak`);
|
||||
*/
|
||||
export async function advanceStreak(apiKeyId: string): Promise<StreakAdvance> {
|
||||
if (isBuildPhase || isCloud) return { currentStreak: 0, extended: false };
|
||||
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const today = todayUtc();
|
||||
@@ -165,19 +197,13 @@ export async function updateStreak(apiKeyId: string): Promise<number> {
|
||||
|
||||
// Already counted today
|
||||
if (streak.lastActiveDate === today) {
|
||||
return streak.currentStreak;
|
||||
return { currentStreak: streak.currentStreak, extended: false };
|
||||
}
|
||||
|
||||
const yesterday = yesterdayUtc();
|
||||
let newStreak: number;
|
||||
|
||||
if (streak.lastActiveDate === yesterday) {
|
||||
// Consecutive day — extend streak
|
||||
newStreak = streak.currentStreak + 1;
|
||||
} else {
|
||||
// Streak broken or first activity — start fresh
|
||||
newStreak = 1;
|
||||
}
|
||||
const extended = streak.lastActiveDate === yesterday;
|
||||
// Consecutive day — extend streak; otherwise streak broken or first activity — start fresh
|
||||
const newStreak = extended ? streak.currentStreak + 1 : 1;
|
||||
|
||||
const newData: StreakData = {
|
||||
currentStreak: newStreak,
|
||||
@@ -192,5 +218,5 @@ export async function updateStreak(apiKeyId: string): Promise<number> {
|
||||
JSON.stringify(newData)
|
||||
);
|
||||
|
||||
return newStreak;
|
||||
return { currentStreak: newStreak, extended };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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", () => {
|
||||
@@ -107,7 +108,10 @@ describe("Gamification Events", () => {
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
assert.equal(countRequestRows(key), 1);
|
||||
assert.equal(leaderboardScore(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);
|
||||
});
|
||||
|
||||
|
||||
212
tests/unit/gamification/streak-badge-xp.test.ts
Normal file
212
tests/unit/gamification/streak-badge-xp.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
|
||||
import { advanceStreak, getStreak } from "../../../src/lib/gamification/streaks";
|
||||
import { XP_REWARDS } from "../../../src/lib/gamification/xp";
|
||||
import { addXp, getXp, unlockBadge } from "../../../src/lib/db/gamification";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
// `XP_REWARDS` documents `streak_bonus` ("per consecutive streak day, multiplied by streak
|
||||
// length") and `badge_unlock`, but the award pipeline never paid either: events.ts kept a
|
||||
// private reward table without them, updateStreak() did not report whether the streak had
|
||||
// just extended, and checkAndUnlockBadge() unlocked badges without XP. These tests pin the
|
||||
// documented rewards and their idempotency guards (once per UTC day, once per badge).
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const STREAK_NS = "gamification:streaks";
|
||||
|
||||
function utcDate(offsetDays: number): string {
|
||||
return new Date(Date.now() - offsetDays * MS_PER_DAY).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function seedStreak(apiKeyId: string, currentStreak: number, lastActiveDaysAgo: number): void {
|
||||
const lastActiveDate = utcDate(lastActiveDaysAgo);
|
||||
getDbInstance()
|
||||
.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
|
||||
.run(
|
||||
STREAK_NS,
|
||||
apiKeyId,
|
||||
JSON.stringify({
|
||||
currentStreak,
|
||||
longestStreak: currentStreak,
|
||||
lastActiveDate,
|
||||
streakStartDate: utcDate(lastActiveDaysAgo + currentStreak - 1),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function auditRows(
|
||||
apiKeyId: string,
|
||||
action: string
|
||||
): Array<{ xp_earned: number; metadata: string | null }> {
|
||||
return getDbInstance()
|
||||
.prepare("SELECT xp_earned, metadata FROM xp_audit_log WHERE api_key_id = ? AND action = ?")
|
||||
.all(apiKeyId, action) as Array<{ xp_earned: number; metadata: string | null }>;
|
||||
}
|
||||
|
||||
function auditTotal(apiKeyId: string): number {
|
||||
const row = getDbInstance()
|
||||
.prepare("SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ?")
|
||||
.get(apiKeyId) as { total: number };
|
||||
return row.total;
|
||||
}
|
||||
|
||||
function leaderboardScore(apiKeyId: string, scope: string): number {
|
||||
const row = getDbInstance()
|
||||
.prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = ?")
|
||||
.get(apiKeyId, scope) as { score: number } | undefined;
|
||||
return row?.score ?? 0;
|
||||
}
|
||||
|
||||
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 user_levels WHERE api_key_id = ?").run(apiKeyId);
|
||||
db.prepare("DELETE FROM user_badges WHERE api_key_id = ?").run(apiKeyId);
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId);
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(STREAK_NS, apiKeyId);
|
||||
}
|
||||
|
||||
describe("streak bonus XP", () => {
|
||||
it("advanceStreak reports whether the streak extended today", async () => {
|
||||
const key = `sb-advance-${Date.now()}`;
|
||||
try {
|
||||
seedStreak(key, 1, 1);
|
||||
const first = await advanceStreak(key);
|
||||
assert.deepEqual(first, { currentStreak: 2, extended: true });
|
||||
const second = await advanceStreak(key);
|
||||
assert.deepEqual(second, { currentStreak: 2, extended: false }, "same day is a no-op");
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("pays streak_bonus x streak length on the day the streak extends", async () => {
|
||||
const key = `sb-pay-${Date.now()}`;
|
||||
try {
|
||||
seedStreak(key, 1, 1); // active yesterday → today's request extends to 2
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
const rows = auditRows(key, "streak_bonus");
|
||||
assert.equal(rows.length, 1, "exactly one streak_bonus audit row");
|
||||
assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 2);
|
||||
assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { streak: 2 });
|
||||
assert.equal((await getStreak(key)).currentStreak, 2);
|
||||
|
||||
const total = auditTotal(key);
|
||||
assert.equal(getXp(key)?.totalXp, total, "user_levels.total_xp matches the audit log");
|
||||
assert.equal(leaderboardScore(key, "global"), total, "global leaderboard credits the bonus");
|
||||
assert.equal(leaderboardScore(key, "weekly"), total);
|
||||
assert.equal(leaderboardScore(key, "monthly"), total);
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("pays the bonus once per UTC day even when requests repeat", async () => {
|
||||
const key = `sb-once-${Date.now()}`;
|
||||
try {
|
||||
seedStreak(key, 4, 1);
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
const rows = auditRows(key, "streak_bonus");
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].xp_earned, XP_REWARDS.streak_bonus * 5);
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pay on the first day of a streak or after a broken streak", async () => {
|
||||
const fresh = `sb-fresh-${Date.now()}`;
|
||||
const broken = `sb-broken-${Date.now()}`;
|
||||
try {
|
||||
await emitGamificationEvent({ apiKeyId: fresh, action: "request" });
|
||||
assert.equal(auditRows(fresh, "streak_bonus").length, 0, "day 1 is not a consecutive day");
|
||||
|
||||
seedStreak(broken, 6, 3); // last active three days ago → streak resets to 1
|
||||
await emitGamificationEvent({ apiKeyId: broken, action: "request" });
|
||||
assert.equal((await getStreak(broken)).currentStreak, 1);
|
||||
assert.equal(auditRows(broken, "streak_bonus").length, 0);
|
||||
} finally {
|
||||
cleanup(fresh);
|
||||
cleanup(broken);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("badge unlock XP", () => {
|
||||
it("unlockBadge reports whether a new row was inserted", () => {
|
||||
const key = `bu-insert-${Date.now()}`;
|
||||
try {
|
||||
assert.equal(unlockBadge(key, "first-token"), true);
|
||||
assert.equal(unlockBadge(key, "first-token"), false, "INSERT OR IGNORE → no new row");
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("pays badge_unlock once per badge when the pipeline unlocks it", async () => {
|
||||
const key = `bu-pay-${Date.now()}`;
|
||||
try {
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" }); // → first-token
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" }); // already earned
|
||||
|
||||
const rows = auditRows(key, "badge_unlock");
|
||||
assert.equal(rows.length, 1, "exactly one badge_unlock audit row");
|
||||
assert.equal(rows[0].xp_earned, XP_REWARDS.badge_unlock);
|
||||
assert.deepEqual(JSON.parse(rows[0].metadata ?? "{}"), { badgeId: "first-token" });
|
||||
|
||||
const total = auditTotal(key);
|
||||
assert.equal(total, 2 * XP_REWARDS.request + XP_REWARDS.badge_unlock);
|
||||
assert.equal(getXp(key)?.totalXp, total);
|
||||
assert.equal(leaderboardScore(key, "global"), total);
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("pays the streak badge and the streak bonus from the same request", async () => {
|
||||
const key = `bu-streak-${Date.now()}`;
|
||||
try {
|
||||
seedStreak(key, 2, 1); // → 3 today: daily-user badge + bonus
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
const badgeRows = auditRows(key, "badge_unlock");
|
||||
const unlocked = badgeRows.map((r) => JSON.parse(r.metadata ?? "{}").badgeId).sort();
|
||||
assert.deepEqual(unlocked, ["daily-user", "first-token"]);
|
||||
assert.equal(auditRows(key, "streak_bonus")[0]?.xp_earned, XP_REWARDS.streak_bonus * 3);
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("recomputes the level after bonus XP, not only after the action XP", async () => {
|
||||
const key = `bu-level-${Date.now()}`;
|
||||
try {
|
||||
// Level 2 needs 282 XP. 280 + 1 (request) = 281 stays level 1; the first-token
|
||||
// badge_unlock XP crosses the threshold, so the level must be synced after it.
|
||||
addXp(key, "request", 280);
|
||||
assert.equal(getXp(key)?.currentLevel, 1);
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
assert.equal(getXp(key)?.totalXp, 280 + XP_REWARDS.request + XP_REWARDS.badge_unlock);
|
||||
assert.equal(getXp(key)?.currentLevel, 2);
|
||||
} finally {
|
||||
cleanup(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the radar_supporter recognition path free of XP", async () => {
|
||||
const identity = `bu-radar-${Date.now()}`;
|
||||
try {
|
||||
await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" });
|
||||
assert.equal(auditRows(identity, "badge_unlock").length, 0);
|
||||
assert.equal(getXp(identity), null);
|
||||
assert.equal(leaderboardScore(identity, "global"), 0);
|
||||
} finally {
|
||||
cleanup(identity);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user