diff --git a/lib/queries.test.ts b/lib/queries.test.ts index bc536b8..a63cf9b 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, @@ -16,12 +19,12 @@ mock.module("./db", () => ({ query: queryMock, })); -const { getTrends, getUserDetail } = await import("./queries"); +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 }, @@ -123,3 +126,51 @@ describe("getUserDetail", () => { expect(queryMock.mock.calls.some(([sql]) => String(sql).includes("token_name"))).toBe(true); }); }); + +describe("getLogs", () => { + beforeEach(() => { + queryMock.mockClear(); + 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 []; + }); + }); + + 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/queries.ts b/lib/queries.ts index c6b273e..9c5b13c 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -692,8 +692,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);