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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user