Merge remote-tracking branch 'origin/main' into codex/api-analytics-hardening

# Conflicts:
#	README.md
#	lib/queries.test.ts
#	lib/queries.ts
This commit is contained in:
2026-05-27 15:30:26 +08:00
4 changed files with 52 additions and 7 deletions

4
.gitignore vendored
View File

@@ -16,6 +16,10 @@
# local worktrees # local worktrees
.worktrees/ .worktrees/
# local agent/runtime artifacts
.playwright-mcp/
agent-orchestrator.yaml
# next.js # next.js
/.next/ /.next/
/out/ /out/

View File

@@ -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. `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 ## Configuration
Create an environment file or export: Create an environment file or export:

View File

@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"; import { beforeEach, describe, expect, mock, test } from "bun:test";
const queryMock = mock(async () => [ type QueryRow = Record<string, unknown>;
type QueryParams = Array<string | number | boolean | null>;
const queryMock = mock(async (): Promise<QueryRow[]> => [
{ {
date: "2026-04-01 13:00:00", date: "2026-04-01 13:00:00",
calls: 1, calls: 1,
@@ -21,7 +24,7 @@ const { getLogs, getTrends, getUserDetail } = await import("./queries");
describe("getTrends", () => { describe("getTrends", () => {
beforeEach(() => { beforeEach(() => {
queryMock.mockClear(); queryMock.mockClear();
queryMock.mockImplementation(async () => [ queryMock.mockImplementation(async (): Promise<QueryRow[]> => [
{ {
date: "2026-04-01 13:00:00", date: "2026-04-01 13:00:00",
calls: 1, calls: 1,
@@ -62,7 +65,7 @@ describe("getUserDetail", () => {
}); });
test("returns token breakdown with nested model rows for user details", async () => { test("returns token breakdown with nested model rows for user details", async () => {
queryMock.mockImplementation(async (sql: string) => { queryMock.mockImplementation(async (sql: string): Promise<QueryRow[]> => {
if (sql.includes("token_models AS")) { if (sql.includes("token_models AS")) {
return [ return [
{ token_name: "prod-key", model: "claude-sonnet-4", calls: 3, tokens: 100, cache_creation: 5, cache_read: 7, quota: 50 }, { 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", () => { describe("getLogs", () => {
beforeEach(() => { beforeEach(() => {
queryMock.mockClear(); queryMock.mockClear();
queryMock.mockImplementation(async (sql: string) => { queryMock.mockImplementation(async (sql: string): Promise<QueryRow[]> => {
if (sql.includes("COUNT(*)::int as total")) { if (sql.includes("SELECT COUNT(*)::int as total")) {
return [{ total: 0 }]; return [{ total: 0 }];
} }
if (sql.includes("SELECT id, display_name FROM users")) { if (sql.includes("SELECT id, display_name FROM users")) {
return []; return [];
} }
if (sql.includes("SELECT id, name FROM channels")) { if (sql.includes("SELECT id, name FROM channels")) {
return []; return [];
} }
return []; return [];
}); });
}); });
@@ -157,4 +163,31 @@ describe("getLogs", () => {
expect(result.page_size).toBe(100); expect(result.page_size).toBe(100);
expect(dataQuery?.[1]).toEqual([100, 0]); 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]);
});
}); });

View File

@@ -56,8 +56,14 @@ export async function getLogs(options: {
let where = timeWhere(params, options.startTs, options.endTs); let where = timeWhere(params, options.startTs, options.endTs);
if (options.username) { if (options.username) {
params.push(options.username); params.push(`%${options.username}%`);
where += ` AND username = $${params.length}`; 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) { if (options.model) {
params.push(options.model); params.push(options.model);