diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1bdb625aaa..e83d223b32 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -169,6 +169,7 @@ import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTele import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts"; +import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts"; import { appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, @@ -3752,18 +3753,7 @@ export async function handleChatCore({ // === /Quota Share POST-hook === // ── Gamification event (fire-and-forget) ── - if (apiKeyInfo?.id) { - try { - const { emitGamificationEvent } = await import("@/lib/gamification/events"); - emitGamificationEvent({ - apiKeyId: apiKeyInfo.id, - action: "request", - metadata: { model, provider }, - }); - } catch (_) { - /* gamification optional */ - } - } + await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); finalizePendingScope(pendingScope, { providerResponse: responseBody, @@ -4212,18 +4202,7 @@ export async function handleChatCore({ } // ── Gamification event (fire-and-forget) ── - if (apiKeyInfo?.id) { - try { - const { emitGamificationEvent } = await import("@/lib/gamification/events"); - emitGamificationEvent({ - apiKeyId: apiKeyInfo.id, - action: "request", - metadata: { model, provider }, - }); - } catch (_) { - /* gamification optional */ - } - } + await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); // ── Plugin onResponse hook (fire-and-forget) ── try { diff --git a/open-sse/handlers/chatCore/gamificationEvent.ts b/open-sse/handlers/chatCore/gamificationEvent.ts new file mode 100644 index 0000000000..ab170667e3 --- /dev/null +++ b/open-sse/handlers/chatCore/gamificationEvent.ts @@ -0,0 +1,28 @@ +/** + * chatCore request gamification event (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore: emits the per-request "request" gamification event, + * fire-and-forget and fail-open. Shared by the non-streaming and streaming success paths (the + * inline block was duplicated verbatim in both). Guards on a missing api-key id and never throws + * to the caller — the underlying emit is intentionally not awaited; behaviour is byte-identical to + * the previous inline blocks. + */ + +export async function emitRequestGamificationEvent(args: { + apiKeyId: string | null | undefined; + model: string | null | undefined; + provider: string | null | undefined; +}): Promise { + if (!args.apiKeyId) return; + try { + const { emitGamificationEvent } = await import("@/lib/gamification/events"); + emitGamificationEvent({ + apiKeyId: args.apiKeyId, + action: "request", + metadata: { model: args.model, provider: args.provider }, + }); + } catch (_) { + /* gamification optional */ + } +} diff --git a/tests/unit/chatcore-gamification-event.test.ts b/tests/unit/chatcore-gamification-event.test.ts new file mode 100644 index 0000000000..edc5afb70c --- /dev/null +++ b/tests/unit/chatcore-gamification-event.test.ts @@ -0,0 +1,74 @@ +// Characterization of emitRequestGamificationEvent — the per-request gamification hook extracted +// from handleChatCore's non-streaming AND streaming success paths (chatCore god-file +// decomposition, #3501). The inline block was duplicated verbatim; this locks the shared helper. +// Uses a real temp DB and polls xp_audit_log (the emit is fire-and-forget). Locks: the missing +// api-key guard (no-op) and that a valid call awards the "request" XP audit row. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-gamification-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { emitRequestGamificationEvent } = await import( + "../../open-sse/handlers/chatCore/gamificationEvent.ts" +); + +function countAuditRows(apiKeyId: string): number { + const row = coreDb + .getDbInstance() + .prepare("SELECT COUNT(*) AS n FROM xp_audit_log WHERE api_key_id = ?") + .get(apiKeyId) as { n: number } | undefined; + return row?.n ?? 0; +} + +async function waitForAuditRows(apiKeyId: string, min: number, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (countAuditRows(apiKeyId) >= min) break; + await new Promise((r) => setTimeout(r, 25)); + } + return countAuditRows(apiKeyId); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("missing apiKeyId is a no-op and never throws", async () => { + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: null, model: "m", provider: "p" }) + ); + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: undefined, model: "m", provider: "p" }) + ); + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: "", model: "m", provider: "p" }) + ); +}); + +test("valid apiKeyId awards a 'request' XP audit row (fire-and-forget)", async () => { + const apiKeyId = "gamify-key-1"; + assert.equal(countAuditRows(apiKeyId), 0); + await emitRequestGamificationEvent({ apiKeyId, model: "gpt-x", provider: "openai" }); + const n = await waitForAuditRows(apiKeyId, 1); + assert.ok(n >= 1, "expected at least one xp_audit_log row for the request action"); +}); + +test("never throws even when the emit path runs fully", async () => { + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: "gamify-key-2", model: null, provider: null }) + ); +});