diff --git a/CHANGELOG.md b/CHANGELOG.md index db97b358ec6..eedf3608137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### ♻️ Code Quality - **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification. +- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture). - **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627. - **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77. diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index ddb84a25933..c9baa4bd6eb 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -81,12 +81,9 @@ const KNOWN_RAW_SQL = new Set([ "src/app/api/logs/export/route.ts", // SELECT de proxy_logs "src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo) "src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo) - "src/app/api/provider-metrics/route.ts", // SELECT … FROM call_logs (agregação) - "src/app/api/search/stats/route.ts", // SELECT … FROM call_logs "src/app/api/settings/export-json/route.ts", // SELECT * de usage_history/domain_* "src/app/api/skills/[id]/route.ts", // UPDATE skills SET dinâmico "src/app/api/usage/analytics/route.ts", // SELECT … FROM usage_history/daily_usage_summary - "src/app/api/v1/search/analytics/route.ts", // SELECT … FROM call_logs (request_type=search) ]); // Módulos sempre excluídos da checagem (a): não são domínio re-exportável. diff --git a/src/app/api/provider-metrics/route.ts b/src/app/api/provider-metrics/route.ts index 90ca80ad7b5..38a16da0107 100644 --- a/src/app/api/provider-metrics/route.ts +++ b/src/app/api/provider-metrics/route.ts @@ -3,12 +3,10 @@ import pino from "pino"; import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; -import { getDbInstance } from "@/lib/db/core"; +import { getProviderMetrics } from "@/lib/db/callLogStats"; const logger = pino({ name: "provider-metrics-api" }); -type JsonRecord = Record; - function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim().length > 0) { @@ -24,46 +22,7 @@ function toNumber(value: unknown): number { */ export async function GET() { try { - const db = getDbInstance(); - const rows = db - .prepare( - `SELECT - c.provider, - COUNT(*) as totalRequests, - SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses, - ROUND(AVG(duration)) as avgLatencyMs, - MAX(timestamp) as lastRequestAt, - MAX( - CASE - WHEN (status IS NOT NULL AND (status < 200 OR status >= 400)) - OR error_summary IS NOT NULL - THEN timestamp - ELSE NULL - END - ) as lastErrorAt, - ( - SELECT c2.status - FROM call_logs c2 - WHERE c2.provider = c.provider - ORDER BY c2.timestamp DESC, c2.id DESC - LIMIT 1 - ) as lastStatus, - ( - SELECT c3.status - FROM call_logs c3 - WHERE c3.provider = c.provider - AND ( - (c3.status IS NOT NULL AND (c3.status < 200 OR c3.status >= 400)) - OR c3.error_summary IS NOT NULL - ) - ORDER BY c3.timestamp DESC, c3.id DESC - LIMIT 1 - ) as lastErrorStatus - FROM call_logs c - WHERE c.provider IS NOT NULL AND c.provider != '-' - GROUP BY c.provider` - ) - .all() as JsonRecord[]; + const rows = getProviderMetrics(); const metrics: Record< string, diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts index 361f1f05c32..bf0992f1a4a 100644 --- a/src/app/api/search/stats/route.ts +++ b/src/app/api/search/stats/route.ts @@ -1,35 +1,24 @@ import { NextResponse } from "next/server"; import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts"; import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts"; -import { getDbInstance } from "@/lib/db/core"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getSearchProviderStats, getRecentSearchLogs } from "@/lib/db/callLogStats"; export async function GET(request: Request) { if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { - const db = getDbInstance(); const cache = getCacheStats(); // Provider aggregate stats — cost is per-query from registry - const providerStats = db - .prepare( - ` - SELECT provider, COUNT(*) as requests, - CAST(AVG(duration) AS INTEGER) as avg_latency_ms - FROM call_logs - WHERE request_type = 'search' - GROUP BY provider - ` - ) - .all(); + const providerStats = getSearchProviderStats(); const providers: Record< string, { requests: number; avg_latency_ms: number; total_cost: number } > = {}; - for (const row of providerStats as any[]) { + for (const row of providerStats) { const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0; providers[row.provider] = { requests: row.requests, @@ -39,23 +28,13 @@ export async function GET(request: Request) { } // Recent searches - const recentRows = db - .prepare( - ` - SELECT request_summary, provider, timestamp - FROM call_logs - WHERE request_type = 'search' - ORDER BY timestamp DESC - LIMIT 10 - ` - ) - .all(); + const recentRows = getRecentSearchLogs(); - const recent_searches = (recentRows as any[]).map((row) => { + const recent_searches = recentRows.map((row) => { let query = ""; let filters = {}; try { - const summary = JSON.parse(row.request_summary); + const summary = JSON.parse(row.request_summary ?? ""); query = summary.query || ""; filters = summary.filters || {}; } catch { diff --git a/src/app/api/v1/search/analytics/route.ts b/src/app/api/v1/search/analytics/route.ts index 68341baeac0..a2cd7e275df 100644 --- a/src/app/api/v1/search/analytics/route.ts +++ b/src/app/api/v1/search/analytics/route.ts @@ -7,40 +7,20 @@ import { NextResponse } from "next/server"; import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts"; -import { getDbInstance } from "@/lib/db/core"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { getSearchAggregateStats, getSearchProviderCounts } from "@/lib/db/callLogStats"; export async function GET(req: Request) { const policy = await enforceApiKeyPolicy(req, "analytics"); if (policy.rejection) return policy.rejection; try { - const db = getDbInstance(); - // Single aggregated query for all scalar metrics — replaces 5 separate round-trips const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); const todayIso = todayStart.toISOString(); - type StatsRow = { - total: number; - today: number; - errors: number; - avg_duration: number | null; - cached: number; - }; - const statsRow = db - .prepare( - `SELECT - COUNT(*) as total, - COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, - COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, - AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, - COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached - FROM call_logs - WHERE request_type = 'search'` - ) - .get(todayIso) as StatsRow | undefined; + const statsRow = getSearchAggregateStats(todayIso); const total = statsRow?.total ?? 0; const today = statsRow?.today ?? 0; @@ -49,13 +29,7 @@ export async function GET(req: Request) { const cached = statsRow?.cached ?? 0; // Per-provider breakdown - const provRows = db - .prepare( - `SELECT provider, COUNT(*) as cnt - FROM call_logs WHERE request_type = 'search' - GROUP BY provider ORDER BY cnt DESC` - ) - .all() as Array<{ provider: string; cnt: number }>; + const provRows = getSearchProviderCounts(); const byProvider: Record = {}; let totalCostUsd = 0; diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts new file mode 100644 index 00000000000..dde8641ad2f --- /dev/null +++ b/src/lib/db/callLogStats.ts @@ -0,0 +1,181 @@ +import { getDbInstance } from "./core"; + +/** + * Aggregation queries over `call_logs` extracted from route handlers. + * + * Hard Rule #5: routes must not embed raw SQL — these queries live here so the + * /api/provider-metrics, /api/search/stats, and /api/v1/search/analytics routes + * can delegate. Read-only aggregation; no writes. + * + * Sliced out of #3500 (call_logs cluster). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ProviderMetricRow { + provider: string; + totalRequests: number; + totalSuccesses: number; + avgLatencyMs: number; + lastRequestAt: string | null; + lastErrorAt: string | null; + lastStatus: number | null; + lastErrorStatus: number | null; +} + +export interface SearchProviderStatRow { + provider: string; + requests: number; + avg_latency_ms: number; +} + +export interface SearchRecentRow { + request_summary: string | null; + provider: string; + timestamp: string; +} + +export interface SearchAggregateStats { + total: number; + today: number; + errors: number; + avg_duration: number | null; + cached: number; +} + +export interface SearchProviderCountRow { + provider: string; + cnt: number; +} + +// --------------------------------------------------------------------------- +// /api/provider-metrics — aggregate per-provider stats +// --------------------------------------------------------------------------- + +/** + * Returns one row per provider with call-level aggregates plus last-status + * subselects. Excludes rows where provider is NULL or '-'. + */ +export function getProviderMetrics(): ProviderMetricRow[] { + const db = getDbInstance(); + return db + .prepare( + `SELECT + c.provider, + COUNT(*) as totalRequests, + SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses, + ROUND(AVG(duration)) as avgLatencyMs, + MAX(timestamp) as lastRequestAt, + MAX( + CASE + WHEN (status IS NOT NULL AND (status < 200 OR status >= 400)) + OR error_summary IS NOT NULL + THEN timestamp + ELSE NULL + END + ) as lastErrorAt, + ( + SELECT c2.status + FROM call_logs c2 + WHERE c2.provider = c.provider + ORDER BY c2.timestamp DESC, c2.id DESC + LIMIT 1 + ) as lastStatus, + ( + SELECT c3.status + FROM call_logs c3 + WHERE c3.provider = c.provider + AND ( + (c3.status IS NOT NULL AND (c3.status < 200 OR c3.status >= 400)) + OR c3.error_summary IS NOT NULL + ) + ORDER BY c3.timestamp DESC, c3.id DESC + LIMIT 1 + ) as lastErrorStatus + FROM call_logs c + WHERE c.provider IS NOT NULL AND c.provider != '-' + GROUP BY c.provider` + ) + .all() as ProviderMetricRow[]; +} + +// --------------------------------------------------------------------------- +// /api/search/stats — search provider aggregates + recent entries +// --------------------------------------------------------------------------- + +/** + * Per-provider request count and average latency for search requests. + */ +export function getSearchProviderStats(): SearchProviderStatRow[] { + const db = getDbInstance(); + return db + .prepare( + ` + SELECT provider, COUNT(*) as requests, + CAST(AVG(duration) AS INTEGER) as avg_latency_ms + FROM call_logs + WHERE request_type = 'search' + GROUP BY provider + ` + ) + .all() as SearchProviderStatRow[]; +} + +/** + * Most recent 10 search entries (request_summary + provider + timestamp). + */ +export function getRecentSearchLogs(): SearchRecentRow[] { + const db = getDbInstance(); + return db + .prepare( + ` + SELECT request_summary, provider, timestamp + FROM call_logs + WHERE request_type = 'search' + ORDER BY timestamp DESC + LIMIT 10 + ` + ) + .all() as SearchRecentRow[]; +} + +// --------------------------------------------------------------------------- +// /api/v1/search/analytics — aggregated search analytics +// --------------------------------------------------------------------------- + +/** + * Single-pass scalar aggregations for all search entries since `todayIso`. + * `todayIso` is the ISO-8601 UTC start-of-day string used for the "today" count. + */ +export function getSearchAggregateStats(todayIso: string): SearchAggregateStats { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT + COUNT(*) as total, + COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, + COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, + AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, + COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached + FROM call_logs + WHERE request_type = 'search'` + ) + .get(todayIso) as SearchAggregateStats | undefined; + return row ?? { total: 0, today: 0, errors: 0, avg_duration: null, cached: 0 }; +} + +/** + * Per-provider request count for search entries, ordered by count descending. + */ +export function getSearchProviderCounts(): SearchProviderCountRow[] { + const db = getDbInstance(); + return db + .prepare( + `SELECT provider, COUNT(*) as cnt + FROM call_logs WHERE request_type = 'search' + GROUP BY provider ORDER BY cnt DESC` + ) + .all() as SearchProviderCountRow[]; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 1c620b5ef5b..6dc2f8f5f28 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -618,3 +618,18 @@ export { export type { ApiKeyContextSource } from "./db/apiKeyContextSources"; export { sumUsageTokensThisMonth } from "./db/usageSummary"; + +export { + getProviderMetrics, + getSearchProviderStats, + getRecentSearchLogs, + getSearchAggregateStats, + getSearchProviderCounts, +} from "./db/callLogStats"; +export type { + ProviderMetricRow, + SearchProviderStatRow, + SearchRecentRow, + SearchAggregateStats, + SearchProviderCountRow, +} from "./db/callLogStats"; diff --git a/tests/unit/db-call-log-stats-3500.test.ts b/tests/unit/db-call-log-stats-3500.test.ts new file mode 100644 index 00000000000..5240ea97f4d --- /dev/null +++ b/tests/unit/db-call-log-stats-3500.test.ts @@ -0,0 +1,262 @@ +/** + * #3500 — call_logs aggregation functions extracted into callLogStats.ts (Hard Rule #5). + * + * Seeds an in-memory temp SQLite DB and asserts each new db function returns the + * correct aggregation. DB handles are released in test.after to prevent Node + * native test runner from hanging (CLAUDE.md PII/Stream Learnings #3). + */ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-callogstats-3500-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/callLogStats.ts"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let _idSeq = 0; +function insertCallLog(row: Record) { + const db = core.getDbInstance(); + const full = { + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "openai/gpt-4.1", + requested_model: null, + provider: "openai", + account: null, + connection_id: null, + duration: 100, + tokens_in: 10, + tokens_out: 20, + cache_source: "upstream", + source_format: null, + target_format: null, + api_key_id: null, + api_key_name: null, + combo_name: null, + combo_step_id: null, + combo_execution_key: null, + error_summary: null, + detail_state: "none", + artifact_relpath: null, + artifact_size_bytes: null, + artifact_sha256: null, + has_request_body: 0, + has_response_body: 0, + has_pipeline_details: 0, + request_summary: null, + request_type: null, + ...row, + id: row.id ?? `log-3500-${++_idSeq}`, + timestamp: row.timestamp ?? new Date().toISOString(), + }; + db.prepare( + `INSERT INTO call_logs ( + id, timestamp, method, path, status, model, requested_model, provider, account, + connection_id, duration, tokens_in, tokens_out, cache_source, source_format, target_format, + api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, + error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, + has_request_body, has_response_body, has_pipeline_details, request_summary, request_type + ) VALUES ( + @id, @timestamp, @method, @path, @status, @model, @requested_model, @provider, @account, + @connection_id, @duration, @tokens_in, @tokens_out, @cache_source, @source_format, @target_format, + @api_key_id, @api_key_name, @combo_name, @combo_step_id, @combo_execution_key, + @error_summary, @detail_state, @artifact_relpath, @artifact_size_bytes, @artifact_sha256, + @has_request_body, @has_response_body, @has_pipeline_details, @request_summary, @request_type + )` + ).run(full); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +test.before(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// getProviderMetrics +// --------------------------------------------------------------------------- + +test("#3500 getProviderMetrics — aggregates totals and latency per provider", () => { + // Two openai rows: one success, one error with error_summary + const ts1 = "2025-06-01T10:00:00.000Z"; + const ts2 = "2025-06-01T11:00:00.000Z"; + insertCallLog({ provider: "openai", status: 200, duration: 100, timestamp: ts1 }); + insertCallLog({ + provider: "openai", + status: 500, + duration: 300, + error_summary: "upstream error", + timestamp: ts2, + }); + // One anthropic success row + const ts3 = "2025-06-01T09:00:00.000Z"; + insertCallLog({ provider: "anthropic", status: 200, duration: 50, timestamp: ts3 }); + // Provider '-' should be excluded + insertCallLog({ provider: "-", status: 200 }); + // Provider null should be excluded (insert directly to avoid type issue) + core.getDbInstance().prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, duration, + tokens_in, tokens_out, cache_source, detail_state, has_request_body, has_response_body, has_pipeline_details) + VALUES (?, ?, 'POST', '/v1/test', 200, 'x', NULL, 100, 0, 0, 'upstream', 'none', 0, 0, 0)` + ).run(`log-3500-null-${++_idSeq}`, new Date().toISOString()); + + const rows = mod.getProviderMetrics(); + + // '-' and null providers must not appear + assert.ok(!rows.some((r) => r.provider === "-"), "provider '-' excluded"); + assert.ok(!rows.some((r) => r.provider === null), "null provider excluded"); + + const openai = rows.find((r) => r.provider === "openai"); + assert.ok(openai, "openai row present"); + assert.equal(openai.totalRequests, 2); + assert.equal(openai.totalSuccesses, 1); // only the 200 + assert.equal(openai.avgLatencyMs, 200); // ROUND((100+300)/2) = 200 + assert.equal(openai.lastRequestAt, ts2, "lastRequestAt = most recent ts"); + assert.equal(openai.lastErrorAt, ts2, "lastErrorAt = ts of the 500/error row"); + assert.equal(openai.lastStatus, 500, "lastStatus = most recent status"); + assert.equal(openai.lastErrorStatus, 500, "lastErrorStatus = most recent error status"); + + const anthropic = rows.find((r) => r.provider === "anthropic"); + assert.ok(anthropic, "anthropic row present"); + assert.equal(anthropic.totalRequests, 1); + assert.equal(anthropic.totalSuccesses, 1); + assert.equal(anthropic.lastErrorAt, null, "no error row → lastErrorAt is null"); + assert.equal(anthropic.lastStatus, 200); + assert.equal(anthropic.lastErrorStatus, null, "no error → lastErrorStatus is null"); +}); + +// --------------------------------------------------------------------------- +// getSearchProviderStats +// --------------------------------------------------------------------------- + +test("#3500 getSearchProviderStats — aggregates search requests per provider", () => { + insertCallLog({ provider: "brave", status: 200, duration: 50, request_type: "search" }); + insertCallLog({ provider: "brave", status: 200, duration: 150, request_type: "search" }); + insertCallLog({ provider: "serper", status: 200, duration: 80, request_type: "search" }); + // non-search row — must NOT appear + insertCallLog({ provider: "openai", status: 200, duration: 100, request_type: null }); + + const rows = mod.getSearchProviderStats(); + + // Only search request_type rows + assert.ok(!rows.some((r) => r.provider === "openai"), "non-search row excluded"); + + const brave = rows.find((r) => r.provider === "brave"); + assert.ok(brave, "brave row present"); + assert.equal(brave.requests, 2); + assert.equal(brave.avg_latency_ms, 100); // CAST(AVG(50,150) AS INTEGER) = 100 + + const serper = rows.find((r) => r.provider === "serper"); + assert.ok(serper, "serper row present"); + assert.equal(serper.requests, 1); +}); + +// --------------------------------------------------------------------------- +// getRecentSearchLogs +// --------------------------------------------------------------------------- + +test("#3500 getRecentSearchLogs — returns up to 10 most recent search rows", () => { + // Insert 12 search rows with incrementing timestamps + for (let i = 1; i <= 12; i++) { + const ts = new Date(Date.UTC(2025, 5, i, 12, 0, 0)).toISOString(); + insertCallLog({ + provider: "brave", + status: 200, + request_type: "search", + timestamp: ts, + request_summary: JSON.stringify({ query: `q${i}` }), + }); + } + + const rows = mod.getRecentSearchLogs(); + assert.equal(rows.length, 10, "limited to 10 rows"); + // Most recent first (i=12 is the newest) + assert.ok(rows[0].timestamp >= rows[rows.length - 1].timestamp, "ordered newest-first"); +}); + +// --------------------------------------------------------------------------- +// getSearchAggregateStats +// --------------------------------------------------------------------------- + +test("#3500 getSearchAggregateStats — correct totals, today, errors, avg, cached", () => { + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const todayIso = todayStart.toISOString(); + + // Rows inserted after todayStart qualify as "today" + const nowIso = new Date().toISOString(); + // duration=0 → excluded from avg_duration; duration=3 → cached (>0 && <5) + insertCallLog({ provider: "brave", status: 200, duration: 100, request_type: "search", timestamp: nowIso }); + insertCallLog({ provider: "brave", status: 200, duration: 3, request_type: "search", timestamp: nowIso }); + insertCallLog({ provider: "brave", status: 500, duration: 80, request_type: "search", timestamp: nowIso }); + // Old row (yesterday) — not in today count + const yesterday = new Date(Date.now() - 86_400_000).toISOString(); + insertCallLog({ provider: "brave", status: 200, duration: 200, request_type: "search", timestamp: yesterday }); + + const result = mod.getSearchAggregateStats(todayIso); + + assert.ok(result.total >= 4, "total includes all search rows (across all tests in file)"); + assert.ok(result.today >= 3, "today counts rows from today"); + assert.ok(result.errors >= 1, "errors counts status >= 400"); + assert.ok(result.cached >= 1, "cached counts duration in (0,5)"); + assert.ok(result.avg_duration !== null, "avg_duration not null when rows have duration > 0"); +}); + +test("#3500 getSearchAggregateStats — returns zero struct when no search rows match", () => { + // Use a far-future todayIso so no row qualifies + const farFuture = "2999-01-01T00:00:00.000Z"; + const result = mod.getSearchAggregateStats(farFuture); + // total should be 0 (no rows WHERE request_type='search' AND timestamp >= 2999…) + // Actually the total counts ALL search rows regardless of todayIso; only "today" is gated. + // The function never returns null, so we just check the shape. + assert.equal(typeof result.total, "number"); + assert.equal(typeof result.today, "number"); + assert.equal(result.today, 0, "no rows qualify as 'today' in far future"); +}); + +// --------------------------------------------------------------------------- +// getSearchProviderCounts +// --------------------------------------------------------------------------- + +test("#3500 getSearchProviderCounts — ordered by cnt desc", () => { + // brave has multiple rows from earlier tests; add a burst to ensure ordering + for (let i = 0; i < 5; i++) { + insertCallLog({ provider: "bing", status: 200, request_type: "search" }); + } + for (let i = 0; i < 2; i++) { + insertCallLog({ provider: "rare_provider", status: 200, request_type: "search" }); + } + + const rows = mod.getSearchProviderCounts(); + // Each row must have provider + cnt + for (const row of rows) { + assert.equal(typeof row.provider, "string"); + assert.equal(typeof row.cnt, "number"); + } + // First row should have count >= last row (ordered desc) + if (rows.length >= 2) { + assert.ok(rows[0].cnt >= rows[rows.length - 1].cnt, "ordered by cnt desc"); + } + // bing (5 added) should beat rare_provider (2 added) if both appear + const bing = rows.find((r) => r.provider === "bing"); + const rare = rows.find((r) => r.provider === "rare_provider"); + if (bing && rare) { + assert.ok(bing.cnt > rare.cnt, "bing cnt > rare_provider cnt"); + } +});