chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// #3484 — the dashboard profile page fetches /api/gamification/{level,badges,badges/earned}
|
||||
// without an apiKeyId (operator-wide view). These helpers back the no-key case and the
|
||||
// badge catalog must be seeded so the grid is populated (see #3472).
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-gami-3484-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
if (!process.env.API_KEY_SECRET) {
|
||||
process.env.API_KEY_SECRET = "test-gami-3484-secret-" + Date.now();
|
||||
}
|
||||
|
||||
const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts");
|
||||
const gami = await import("../../../src/lib/db/gamification.ts");
|
||||
const { seedBuiltinBadges, BUILTIN_BADGES } = await import("../../../src/lib/gamification/badges.ts");
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
getDbInstance().close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
resetDbInstance();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#3484 getAggregateXp on an empty ledger → zero XP, level 1, no throw", () => {
|
||||
const agg = gami.getAggregateXp();
|
||||
assert.equal(agg.totalXp, 0);
|
||||
assert.equal(agg.currentLevel, 1);
|
||||
assert.equal(agg.apiKeyId, "*");
|
||||
});
|
||||
|
||||
test("#3484 seedBuiltinBadges populates the catalog (getBadgeDefinitions non-empty)", async () => {
|
||||
assert.equal(gami.getBadgeDefinitions().length, 0); // unseeded
|
||||
await seedBuiltinBadges();
|
||||
assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length);
|
||||
await seedBuiltinBadges(); // idempotent — no duplicates
|
||||
assert.equal(gami.getBadgeDefinitions().length, BUILTIN_BADGES.length);
|
||||
});
|
||||
|
||||
test("#3484 getAggregateXp sums XP across keys and takes the highest level", () => {
|
||||
const db = getDbInstance();
|
||||
const upsert = db.prepare(
|
||||
`INSERT OR REPLACE INTO user_levels (api_key_id, total_xp, current_level, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`
|
||||
);
|
||||
upsert.run("key-a", 100, 2);
|
||||
upsert.run("key-b", 250, 5);
|
||||
|
||||
const agg = gami.getAggregateXp();
|
||||
assert.equal(agg.totalXp, 350);
|
||||
assert.equal(agg.currentLevel, 5);
|
||||
});
|
||||
|
||||
test("#3484 getAllEarnedBadges returns distinct badges earned by any key", () => {
|
||||
const db = getDbInstance();
|
||||
const [b0, b1] = BUILTIN_BADGES;
|
||||
const award = db.prepare(
|
||||
`INSERT OR IGNORE INTO user_badges (api_key_id, badge_id, unlocked_at)
|
||||
VALUES (?, ?, datetime('now'))`
|
||||
);
|
||||
award.run("key-a", b0.id); // earned by A
|
||||
award.run("key-b", b0.id); // same badge earned by B → must dedupe to one
|
||||
award.run("key-b", b1.id); // distinct badge earned by B
|
||||
|
||||
const earned = gami.getAllEarnedBadges();
|
||||
const ids = earned.map((e) => e.badgeId).sort();
|
||||
assert.deepEqual(ids, [b0.id, b1.id].sort());
|
||||
assert.ok(earned.every((e) => typeof e.badgeName === "string" && e.badgeName.length > 0));
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateScoreChange, getAnomalies } from "../../../src/lib/gamification/antiCheat";
|
||||
|
||||
describe("Anti-Cheat", () => {
|
||||
describe("validateScoreChange", () => {
|
||||
it("allows normal score changes", async () => {
|
||||
const result = await validateScoreChange("test-user", "request", 1);
|
||||
assert.equal(result.allowed, true);
|
||||
});
|
||||
|
||||
it("rejects excessive XP", async () => {
|
||||
const result = await validateScoreChange("test-user", "request", 999999);
|
||||
assert.equal(result.allowed, false);
|
||||
assert.ok(result.reason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnomalies", () => {
|
||||
it("returns array", async () => {
|
||||
const anomalies = await getAnomalies();
|
||||
assert.ok(Array.isArray(anomalies));
|
||||
});
|
||||
|
||||
it("returns entries with numeric zScore (not hardcoded 0)", async () => {
|
||||
const anomalies = await getAnomalies();
|
||||
for (const a of anomalies) {
|
||||
assert.equal(typeof a.zScore, "number");
|
||||
assert.ok(!Number.isNaN(a.zScore));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { unlockBadge, getBadges, hasBadge } from "../../../src/lib/db/gamification";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
// Regression for #3472: badge_definitions is empty in production (seedBuiltinBadges is never
|
||||
// wired at startup). The old "already unlocked?" guard in checkAndUnlockBadge used getBadges(),
|
||||
// which INNER-JOINs badge_definitions — so it returned [] even after a badge was awarded, and
|
||||
// the unlock event (events.badge_unlocked) was re-emitted on EVERY request. The guard must read
|
||||
// user_badges directly so dedup works regardless of whether badge_definitions is populated.
|
||||
|
||||
describe("#3472 badge unlock dedup is independent of badge_definitions", () => {
|
||||
it("hasBadge() sees an awarded badge even when badge_definitions has no matching row", () => {
|
||||
const key = `t3472-${Date.now()}`;
|
||||
const badgeId = `first-token-3472-${Date.now()}`;
|
||||
const db = getDbInstance();
|
||||
try {
|
||||
unlockBadge(key, badgeId);
|
||||
// getBadges INNER-JOINs badge_definitions → blind to this award (the bug surface).
|
||||
assert.equal(getBadges(key).length, 0, "getBadges is blind without a definition row");
|
||||
// The fixed guard reads user_badges directly.
|
||||
assert.equal(hasBadge(key, badgeId), true, "hasBadge must see the awarded badge");
|
||||
} finally {
|
||||
db.prepare("DELETE FROM user_badges WHERE api_key_id = ?").run(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("hasBadge() is false before award and a repeated unlock stays a single row", () => {
|
||||
const key = `t3472b-${Date.now()}`;
|
||||
const badgeId = `token-consumer-3472-${Date.now()}`;
|
||||
const db = getDbInstance();
|
||||
try {
|
||||
assert.equal(hasBadge(key, badgeId), false, "no badge before award");
|
||||
unlockBadge(key, badgeId);
|
||||
unlockBadge(key, badgeId); // INSERT OR IGNORE → still one row
|
||||
assert.equal(hasBadge(key, badgeId), true);
|
||||
const count = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM user_badges WHERE api_key_id = ? AND badge_id = ?")
|
||||
.get(key, badgeId) as { n: number };
|
||||
assert.equal(count.n, 1, "exactly one badge row after repeated unlock");
|
||||
} finally {
|
||||
db.prepare("DELETE FROM user_badges WHERE api_key_id = ?").run(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BUILTIN_BADGES } from "../../../src/lib/gamification/badges";
|
||||
|
||||
describe("Badge Definitions", () => {
|
||||
describe("BUILTIN_BADGES", () => {
|
||||
it("has at least 20 badges", () => {
|
||||
assert.ok(BUILTIN_BADGES.length >= 20, `Expected >= 20, got ${BUILTIN_BADGES.length}`);
|
||||
});
|
||||
|
||||
it("all badges have required fields", () => {
|
||||
for (const badge of BUILTIN_BADGES) {
|
||||
assert.ok(badge.id, `Badge missing id`);
|
||||
assert.ok(badge.name, `Badge ${badge.id} missing name`);
|
||||
assert.ok(badge.description, `Badge ${badge.id} missing description`);
|
||||
assert.ok(badge.category, `Badge ${badge.id} missing category`);
|
||||
assert.ok(badge.rarity, `Badge ${badge.id} missing rarity`);
|
||||
assert.ok(badge.criteria, `Badge ${badge.id} missing criteria`);
|
||||
}
|
||||
});
|
||||
|
||||
it("all badge IDs are unique", () => {
|
||||
const ids = BUILTIN_BADGES.map((b) => b.id);
|
||||
const unique = new Set(ids);
|
||||
assert.equal(ids.length, unique.size, "Duplicate badge IDs found");
|
||||
});
|
||||
|
||||
it("all criteria are valid JSON", () => {
|
||||
for (const badge of BUILTIN_BADGES) {
|
||||
assert.doesNotThrow(
|
||||
() => JSON.parse(badge.criteria),
|
||||
`Badge ${badge.id} has invalid criteria JSON`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("has badges in all categories", () => {
|
||||
const categories = new Set(BUILTIN_BADGES.map((b) => b.category));
|
||||
assert.ok(categories.has("usage"), "Missing usage category");
|
||||
assert.ok(categories.has("sharing"), "Missing sharing category");
|
||||
assert.ok(categories.has("contribution"), "Missing contribution category");
|
||||
assert.ok(categories.has("streak"), "Missing streak category");
|
||||
});
|
||||
|
||||
it("has badges in all rarities", () => {
|
||||
const rarities = new Set(BUILTIN_BADGES.map((b) => b.rarity));
|
||||
assert.ok(rarities.has("common"), "Missing common rarity");
|
||||
assert.ok(rarities.has("uncommon"), "Missing uncommon rarity");
|
||||
assert.ok(rarities.has("rare"), "Missing rare rarity");
|
||||
assert.ok(rarities.has("legendary"), "Missing legendary rarity");
|
||||
});
|
||||
|
||||
it("usage badges have action_count criteria", () => {
|
||||
const usageBadges = BUILTIN_BADGES.filter((b) => b.category === "usage");
|
||||
for (const badge of usageBadges) {
|
||||
const criteria = JSON.parse(badge.criteria);
|
||||
assert.equal(
|
||||
criteria.type,
|
||||
"action_count",
|
||||
`Badge ${badge.id} should have action_count type`
|
||||
);
|
||||
assert.ok(criteria.threshold > 0, `Badge ${badge.id} threshold should be positive`);
|
||||
}
|
||||
});
|
||||
|
||||
it("streak badges have streak criteria", () => {
|
||||
const streakBadges = BUILTIN_BADGES.filter((b) => b.category === "streak");
|
||||
for (const badge of streakBadges) {
|
||||
const criteria = JSON.parse(badge.criteria);
|
||||
assert.equal(criteria.type, "streak", `Badge ${badge.id} should have streak type`);
|
||||
assert.ok(criteria.threshold > 0, `Badge ${badge.id} threshold should be positive`);
|
||||
}
|
||||
});
|
||||
|
||||
it("sharing badges have action_count criteria", () => {
|
||||
const sharingBadges = BUILTIN_BADGES.filter((b) => b.category === "sharing");
|
||||
for (const badge of sharingBadges) {
|
||||
const criteria = JSON.parse(badge.criteria);
|
||||
assert.equal(
|
||||
criteria.type,
|
||||
"action_count",
|
||||
`Badge ${badge.id} should have action_count type`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { addXp, getXp } from "../../../src/lib/db/gamification";
|
||||
import { calculateLevel } from "../../../src/lib/gamification/xp";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("DB Gamification — addXp level computation", () => {
|
||||
it("sets correct level for large initial XP", () => {
|
||||
const testKey = `test-addxp-level-${Date.now()}`;
|
||||
addXp(testKey, "invite_redeem", 50000);
|
||||
|
||||
const xp = getXp(testKey);
|
||||
assert.ok(xp);
|
||||
assert.equal(xp.currentLevel, calculateLevel(50000));
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
|
||||
it("sets level 1 for small initial XP", () => {
|
||||
const testKey = `test-addxp-small-${Date.now()}`;
|
||||
addXp(testKey, "request", 1);
|
||||
|
||||
const xp = getXp(testKey);
|
||||
assert.ok(xp);
|
||||
assert.equal(xp.currentLevel, 1);
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("Federation Leaderboard Auth", () => {
|
||||
it("rejects requests without Authorization header", async () => {
|
||||
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
|
||||
|
||||
const { NextRequest } = await import("next/server");
|
||||
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard");
|
||||
|
||||
const response = await GET(req);
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
it("rejects requests with invalid bearer token", async () => {
|
||||
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard", {
|
||||
headers: { Authorization: "Bearer invalid-token-12345" },
|
||||
});
|
||||
|
||||
const response = await GET(req);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createInvite,
|
||||
redeemInvite,
|
||||
listInvites,
|
||||
revokeInvite,
|
||||
} from "../../../src/lib/gamification/invites";
|
||||
|
||||
describe("Invite Tokens", () => {
|
||||
const testKeyId = `test-invite-${Date.now()}`;
|
||||
let inviteCode: string;
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
const { getDbInstance } = require("../../../src/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM invite_tokens WHERE created_by LIKE ?").run("test-invite-%");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
describe("createInvite", () => {
|
||||
it("returns code and token", async () => {
|
||||
const result = await createInvite(testKeyId);
|
||||
assert.ok(result.code);
|
||||
assert.ok(result.token);
|
||||
assert.equal(result.code.length, 8);
|
||||
inviteCode = result.code;
|
||||
});
|
||||
|
||||
it("creates unique codes", async () => {
|
||||
const r1 = await createInvite(testKeyId);
|
||||
const r2 = await createInvite(testKeyId);
|
||||
assert.notEqual(r1.code, r2.code);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redeemInvite", () => {
|
||||
it("rejects invalid code", async () => {
|
||||
const result = await redeemInvite("INVALID", "other-user");
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.error?.includes("Invalid"));
|
||||
});
|
||||
|
||||
it("rejects self-redemption", async () => {
|
||||
const result = await redeemInvite(inviteCode, testKeyId);
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.error?.includes("your own"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("listInvites", () => {
|
||||
it("returns array", async () => {
|
||||
const invites = await listInvites(testKeyId);
|
||||
assert.ok(Array.isArray(invites));
|
||||
assert.ok(invites.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeInvite", () => {
|
||||
it("revokes successfully", async () => {
|
||||
const invites = await listInvites(testKeyId);
|
||||
if (invites.length > 0) {
|
||||
await revokeInvite(invites[0].id);
|
||||
// Verify revoked
|
||||
const result = await redeemInvite(invites[0].code, "other-user");
|
||||
assert.equal(result.success, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
updateScore,
|
||||
getRank,
|
||||
getTopN,
|
||||
getNeighbors,
|
||||
} from "../../../src/lib/gamification/leaderboard";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("Leaderboard Engine", () => {
|
||||
const testKey = `test-lb-${Date.now()}`;
|
||||
|
||||
after(() => {
|
||||
// Cleanup
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id LIKE ?").run("test-lb-%");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
describe("updateScore", () => {
|
||||
it("creates score entry", async () => {
|
||||
await updateScore(testKey, "global", 100);
|
||||
const rank = await getRank(testKey, "global");
|
||||
assert.ok(rank >= 1);
|
||||
});
|
||||
|
||||
it("increments score", async () => {
|
||||
await updateScore(testKey, "global", 50);
|
||||
const top = await getTopN("global", 100);
|
||||
const entry = top.find((e: any) => (e.apiKeyId || e.api_key_id) === testKey);
|
||||
assert.ok(entry);
|
||||
assert.ok((entry.score || 0) >= 150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRank", () => {
|
||||
it("returns rank for existing user", async () => {
|
||||
const rank = await getRank(testKey, "global");
|
||||
assert.ok(rank >= 1);
|
||||
});
|
||||
|
||||
it("returns 0 for non-existent user", async () => {
|
||||
const rank = await getRank("nonexistent", "global");
|
||||
assert.equal(rank, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopN", () => {
|
||||
it("returns entries", async () => {
|
||||
const entries = await getTopN("global", 10);
|
||||
assert.ok(Array.isArray(entries));
|
||||
});
|
||||
|
||||
it("respects limit", async () => {
|
||||
const entries = await getTopN("global", 5);
|
||||
assert.ok(entries.length <= 5);
|
||||
});
|
||||
|
||||
it("returns different results with offset", async () => {
|
||||
// Seed multiple entries with distinct scores
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const key = `test-offset-${Date.now()}-${i}`;
|
||||
keys.push(key);
|
||||
await updateScore(key, "global", (10 - i) * 100);
|
||||
}
|
||||
|
||||
const page1 = await getTopN("global", 5, 0);
|
||||
const page2 = await getTopN("global", 5, 5);
|
||||
|
||||
// Pages should not be identical
|
||||
const page1Ids = page1.map((e: any) => e.apiKeyId || e.api_key_id);
|
||||
const page2Ids = page2.map((e: any) => e.apiKeyId || e.api_key_id);
|
||||
const overlap = page1Ids.filter((id: string) => page2Ids.includes(id));
|
||||
assert.equal(overlap.length, 0, "Pages should not overlap");
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
for (const key of keys) {
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("offset 0 returns same as no offset", async () => {
|
||||
const withOffset = await getTopN("global", 5, 0);
|
||||
const withoutOffset = await getTopN("global", 5);
|
||||
assert.equal(withOffset.length, withoutOffset.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNeighbors", () => {
|
||||
it("returns above and below", async () => {
|
||||
const result = await getNeighbors(testKey, "global");
|
||||
assert.ok("above" in result);
|
||||
assert.ok("below" in result);
|
||||
assert.ok(Array.isArray(result.above));
|
||||
assert.ok(Array.isArray(result.below));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { transferTokens, getBalance, getHistory } from "../../../src/lib/gamification/sharing";
|
||||
|
||||
describe("Token Sharing", () => {
|
||||
describe("transferTokens", () => {
|
||||
it("rejects self-transfer", async () => {
|
||||
const result = await transferTokens("user1", "user1", 100);
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.error?.includes("yourself"));
|
||||
});
|
||||
|
||||
it("rejects zero amount", async () => {
|
||||
const result = await transferTokens("user1", "user2", 0);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects negative amount", async () => {
|
||||
const result = await transferTokens("user1", "user2", -100);
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("returns idempotency key on success", async () => {
|
||||
// This will fail due to insufficient balance, but validates the flow
|
||||
const result = await transferTokens("user1", "user2", 100);
|
||||
// Either succeeds or fails with balance error — idempotencyKey should be a string
|
||||
assert.equal(typeof result.idempotencyKey, "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBalance", () => {
|
||||
it("returns number", async () => {
|
||||
const balance = await getBalance("test-user");
|
||||
assert.equal(typeof balance, "number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHistory", () => {
|
||||
it("returns array", async () => {
|
||||
const history = await getHistory("test-user");
|
||||
assert.ok(Array.isArray(history));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getStreak, updateStreak } from "../../../src/lib/gamification/streaks";
|
||||
|
||||
describe("Streak Tracker", () => {
|
||||
describe("getStreak", () => {
|
||||
it("returns zero streak for unknown user", async () => {
|
||||
const streak = await getStreak("nonexistent-user");
|
||||
assert.equal(streak.currentStreak, 0);
|
||||
assert.equal(streak.longestStreak, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStreak", () => {
|
||||
it("returns positive streak count", async () => {
|
||||
const streak = await updateStreak("test-user-1");
|
||||
assert.ok(streak >= 1);
|
||||
});
|
||||
|
||||
it("returns same count if called twice same day", async () => {
|
||||
const first = await updateStreak("test-user-2");
|
||||
const second = await updateStreak("test-user-2");
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
it("stores date metadata when starting a streak", async () => {
|
||||
await updateStreak("test-user-3");
|
||||
|
||||
const streak = await getStreak("test-user-3");
|
||||
assert.equal(streak.currentStreak, 1);
|
||||
assert.equal(streak.longestStreak, 1);
|
||||
assert.match(streak.lastActiveDate, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(streak.streakStartDate, streak.lastActiveDate);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
calculateLevel,
|
||||
xpForLevel,
|
||||
cumulativeXpForLevel,
|
||||
xpToNextLevel,
|
||||
getLevelTitle,
|
||||
getLevelTier,
|
||||
XP_REWARDS,
|
||||
} from "../../../src/lib/gamification/xp";
|
||||
|
||||
describe("XP/Level Engine", () => {
|
||||
describe("calculateLevel", () => {
|
||||
it("returns level 1 for 0 XP", () => {
|
||||
assert.equal(calculateLevel(0), 1);
|
||||
});
|
||||
|
||||
it("returns level 1 for negative XP", () => {
|
||||
assert.equal(calculateLevel(-100), 1);
|
||||
});
|
||||
|
||||
it("returns level 1 for small XP", () => {
|
||||
assert.equal(calculateLevel(50), 1);
|
||||
});
|
||||
|
||||
it("returns level ~5 for 3162 XP (xpForLevel(10))", () => {
|
||||
const level = calculateLevel(3162);
|
||||
assert.ok(level >= 4 && level <= 7, `Expected ~5, got ${level}`);
|
||||
});
|
||||
|
||||
it("returns level ~10 for cumulative level-10 XP", () => {
|
||||
const xp = cumulativeXpForLevel(10);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 9 && level <= 11, `Expected ~10, got ${level}`);
|
||||
});
|
||||
|
||||
it("returns level ~25 for cumulative level-25 XP", () => {
|
||||
const xp = cumulativeXpForLevel(25);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 24 && level <= 26, `Expected ~25, got ${level}`);
|
||||
});
|
||||
|
||||
it("returns level ~50 for cumulative level-50 XP", () => {
|
||||
const xp = cumulativeXpForLevel(50);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 49 && level <= 51, `Expected ~50, got ${level}`);
|
||||
});
|
||||
|
||||
it("monotonically increases", () => {
|
||||
let prev = 0;
|
||||
for (let xp = 0; xp <= 100000; xp += 1000) {
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= prev, `Level decreased at xp=${xp}: ${level} < ${prev}`);
|
||||
prev = level;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("xpForLevel", () => {
|
||||
it("returns 0 for level 1", () => {
|
||||
assert.equal(xpForLevel(1), 0);
|
||||
});
|
||||
|
||||
it("returns positive XP for level > 1", () => {
|
||||
assert.ok(xpForLevel(2) > 0);
|
||||
});
|
||||
|
||||
it("increases with level", () => {
|
||||
assert.ok(xpForLevel(10) > xpForLevel(5));
|
||||
assert.ok(xpForLevel(50) > xpForLevel(10));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cumulativeXpForLevel", () => {
|
||||
it("returns 0 for level 1", () => {
|
||||
assert.equal(cumulativeXpForLevel(1), 0);
|
||||
});
|
||||
|
||||
it("increases with level", () => {
|
||||
assert.ok(cumulativeXpForLevel(10) > cumulativeXpForLevel(5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("xpToNextLevel", () => {
|
||||
it("returns positive number", () => {
|
||||
assert.ok(xpToNextLevel(0) > 0);
|
||||
});
|
||||
|
||||
it("decreases as XP increases within same level", () => {
|
||||
const at100 = xpToNextLevel(100);
|
||||
const at200 = xpToNextLevel(200);
|
||||
// Both should be level 1, but at200 is closer to level 2
|
||||
if (calculateLevel(100) === calculateLevel(200)) {
|
||||
assert.ok(at200 < at100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLevelTitle", () => {
|
||||
it("returns Beginner for level 1", () => {
|
||||
assert.equal(getLevelTitle(1), "Beginner");
|
||||
});
|
||||
|
||||
it("returns Explorer for level 11", () => {
|
||||
assert.equal(getLevelTitle(11), "Explorer");
|
||||
});
|
||||
|
||||
it("returns Expert for level 26", () => {
|
||||
assert.equal(getLevelTitle(26), "Expert");
|
||||
});
|
||||
|
||||
it("returns Master for level 51", () => {
|
||||
assert.equal(getLevelTitle(51), "Master");
|
||||
});
|
||||
|
||||
it("returns Legend for level 76", () => {
|
||||
assert.equal(getLevelTitle(76), "Legend");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLevelTier", () => {
|
||||
it("returns bronze for level 1", () => {
|
||||
assert.equal(getLevelTier(1), "bronze");
|
||||
});
|
||||
|
||||
it("returns silver for level 11", () => {
|
||||
assert.equal(getLevelTier(11), "silver");
|
||||
});
|
||||
|
||||
it("returns gold for level 26", () => {
|
||||
assert.equal(getLevelTier(26), "gold");
|
||||
});
|
||||
|
||||
it("returns platinum for level 51", () => {
|
||||
assert.equal(getLevelTier(51), "platinum");
|
||||
});
|
||||
|
||||
it("returns diamond for level 76", () => {
|
||||
assert.equal(getLevelTier(76), "diamond");
|
||||
});
|
||||
});
|
||||
|
||||
describe("XP_REWARDS", () => {
|
||||
it("has all expected actions", () => {
|
||||
assert.ok("request" in XP_REWARDS);
|
||||
assert.ok("provider_switch" in XP_REWARDS);
|
||||
assert.ok("combo_create" in XP_REWARDS);
|
||||
assert.ok("token_share" in XP_REWARDS);
|
||||
assert.ok("daily_login" in XP_REWARDS);
|
||||
});
|
||||
|
||||
it("all rewards are positive", () => {
|
||||
for (const [key, value] of Object.entries(XP_REWARDS)) {
|
||||
assert.ok(value > 0, `${key} should be positive, got ${value}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user