diff --git a/.gitignore b/.gitignore index 33da7ea..35623a0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ # local worktrees .worktrees/ +# local agent/runtime artifacts +.playwright-mcp/ +agent-orchestrator.yaml + # next.js /.next/ /out/ diff --git a/README.md b/README.md index dcc0f0f..ffeab2f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ bun run build `bun test` covers parser helpers, metric conversion, query behavior, cache behavior, theme helpers, and selected API route guardrails. +Smoke test: AO/Codex can update this README, pass lint, and open a GitLab MR. + ## Configuration Create an environment file or export: diff --git a/lib/queries.test.ts b/lib/queries.test.ts index cc559fd..061bc43 100644 --- a/lib/queries.test.ts +++ b/lib/queries.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -const queryMock = mock(async () => [ +type QueryRow = Record; +type QueryParams = Array; + +const queryMock = mock(async (): Promise => [ { date: "2026-04-01 13:00:00", calls: 1, @@ -21,7 +24,7 @@ const { getLogs, getTrends, getUserDetail } = await import("./queries"); describe("getTrends", () => { beforeEach(() => { queryMock.mockClear(); - queryMock.mockImplementation(async () => [ + queryMock.mockImplementation(async (): Promise => [ { date: "2026-04-01 13:00:00", calls: 1, @@ -62,7 +65,7 @@ describe("getUserDetail", () => { }); test("returns token breakdown with nested model rows for user details", async () => { - queryMock.mockImplementation(async (sql: string) => { + queryMock.mockImplementation(async (sql: string): Promise => { if (sql.includes("token_models AS")) { return [ { token_name: "prod-key", model: "claude-sonnet-4", calls: 3, tokens: 100, cache_creation: 5, cache_read: 7, quota: 50 }, @@ -127,16 +130,19 @@ describe("getUserDetail", () => { describe("getLogs", () => { beforeEach(() => { queryMock.mockClear(); - queryMock.mockImplementation(async (sql: string) => { - if (sql.includes("COUNT(*)::int as total")) { + queryMock.mockImplementation(async (sql: string): Promise => { + if (sql.includes("SELECT COUNT(*)::int as total")) { return [{ total: 0 }]; } + if (sql.includes("SELECT id, display_name FROM users")) { return []; } + if (sql.includes("SELECT id, name FROM channels")) { return []; } + return []; }); }); @@ -157,4 +163,31 @@ describe("getLogs", () => { expect(result.page_size).toBe(100); expect(dataQuery?.[1]).toEqual([100, 0]); }); + + test("filters logs by fuzzy display name through the users table", async () => { + await getLogs({ startTs: 501, endTs: 601, username: "张三" }); + + const countCall = queryMock.mock.calls[0]; + const countSql = String(countCall[0]); + const countParams = countCall[1] as QueryParams; + + expect(countSql).toContain("username ILIKE $3"); + expect(countSql).toContain("SELECT id FROM users"); + expect(countSql).toContain("display_name ILIKE $3"); + expect(countSql).toContain("users.username ILIKE $3"); + expect(countParams).toEqual([501, 601, "%张三%"]); + }); + + test("uses the same fuzzy user filter for paginated log rows", async () => { + await getLogs({ page: 2, pageSize: 25, username: "adm" }); + + const dataCall = queryMock.mock.calls[3]; + const dataSql = String(dataCall[0]); + const dataParams = dataCall[1] as QueryParams; + + expect(dataSql).toContain("username ILIKE $1"); + expect(dataSql).toContain("display_name ILIKE $1"); + expect(dataSql).toContain("users.username ILIKE $1"); + expect(dataParams).toEqual(["%adm%", 25, 25]); + }); }); diff --git a/lib/query-logs.ts b/lib/query-logs.ts index ac508c5..e6bc2ec 100644 --- a/lib/query-logs.ts +++ b/lib/query-logs.ts @@ -56,8 +56,14 @@ export async function getLogs(options: { let where = timeWhere(params, options.startTs, options.endTs); if (options.username) { - params.push(options.username); - where += ` AND username = $${params.length}`; + params.push(`%${options.username}%`); + where += ` AND ( + username ILIKE $${params.length} + OR user_id IN ( + SELECT id FROM users + WHERE display_name ILIKE $${params.length} OR users.username ILIKE $${params.length} + ) + )`; } if (options.model) { params.push(options.model);