postgresai
Advanced tools
+373
| import { formatHttpError, maskSecret, normalizeBaseUrl } from "./util"; | ||
| // ============================================================================ | ||
| // Types | ||
| // ============================================================================ | ||
| export interface CheckupReport { | ||
| id: number; | ||
| org_id: number; | ||
| org_name: string; | ||
| project_id: number; | ||
| project_name: string; | ||
| created_at: string; | ||
| created_formatted: string; | ||
| epoch: number; | ||
| status: string; | ||
| } | ||
| export interface CheckupReportFile { | ||
| id: number; | ||
| checkup_report_id: number; | ||
| filename: string; | ||
| check_id: string; | ||
| type: "json" | "md"; | ||
| created_at: string; | ||
| created_formatted: string; | ||
| project_id: number; | ||
| project_name: string; | ||
| } | ||
| export interface CheckupReportFileData extends CheckupReportFile { | ||
| data: string; | ||
| } | ||
| // ============================================================================ | ||
| // Date parsing | ||
| // ============================================================================ | ||
| /** | ||
| * Parse a date string in various formats into an ISO 8601 string. | ||
| * Supported formats: | ||
| * YYYY-MM-DD 2025-01-15 | ||
| * YYYY-MM-DDTHH:mm:ss 2025-01-15T10:30:00 | ||
| * YYYY-MM-DD HH:mm:ss 2025-01-15 10:30:00 | ||
| * YYYY-MM-DD HH:mm 2025-01-15 10:30 | ||
| * DD.MM.YYYY 15.01.2025 | ||
| * DD.MM.YYYY HH:mm 15.01.2025 10:30 | ||
| * DD.MM.YYYY HH:mm:ss 15.01.2025 10:30:00 | ||
| */ | ||
| export function parseFlexibleDate(input: string): string { | ||
| const s = input.trim(); | ||
| // DD.MM.YYYY [HH:mm[:ss]] | ||
| const dotMatch = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/); | ||
| if (dotMatch) { | ||
| const [, dd, mm, yyyy, hh, min, ss] = dotMatch; | ||
| const iso = `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${(hh ?? "00").padStart(2, "0")}:${(min ?? "00").padStart(2, "0")}:${(ss ?? "00").padStart(2, "0")}Z`; | ||
| const d = new Date(iso); | ||
| if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`); | ||
| return d.toISOString(); | ||
| } | ||
| // YYYY-MM-DD[T ]HH:mm[:ss] or YYYY-MM-DD | ||
| const isoMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/); | ||
| if (isoMatch) { | ||
| const [, yyyy, mm, dd, hh, min, ss] = isoMatch; | ||
| const iso = `${yyyy}-${mm}-${dd}T${hh ?? "00"}:${min ?? "00"}:${ss ?? "00"}Z`; | ||
| const d = new Date(iso); | ||
| if (isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`); | ||
| return d.toISOString(); | ||
| } | ||
| throw new Error(`Unrecognized date format: ${input}. Use YYYY-MM-DD or DD.MM.YYYY`); | ||
| } | ||
| // ============================================================================ | ||
| // Params | ||
| // ============================================================================ | ||
| export interface FetchReportsParams { | ||
| apiKey: string; | ||
| apiBaseUrl: string; | ||
| projectId?: number; | ||
| status?: string; | ||
| limit?: number; | ||
| beforeDate?: string; | ||
| /** @internal Used by fetchAllReports for keyset pagination */ | ||
| beforeId?: number; | ||
| debug?: boolean; | ||
| } | ||
| export interface FetchReportFilesParams { | ||
| apiKey: string; | ||
| apiBaseUrl: string; | ||
| reportId?: number; | ||
| type?: "json" | "md"; | ||
| checkId?: string; | ||
| debug?: boolean; | ||
| } | ||
| export interface FetchReportFileDataParams { | ||
| apiKey: string; | ||
| apiBaseUrl: string; | ||
| reportId?: number; | ||
| type?: "json" | "md"; | ||
| checkId?: string; | ||
| debug?: boolean; | ||
| } | ||
| // ============================================================================ | ||
| // API functions | ||
| // ============================================================================ | ||
| export async function fetchReports(params: FetchReportsParams): Promise<CheckupReport[]> { | ||
| const { apiKey, apiBaseUrl, projectId, status, limit = 20, beforeDate, beforeId, debug } = params; | ||
| if (!apiKey) { | ||
| throw new Error("API key is required"); | ||
| } | ||
| const base = normalizeBaseUrl(apiBaseUrl); | ||
| const url = new URL(`${base}/checkup_reports`); | ||
| url.searchParams.set("order", "id.desc"); | ||
| url.searchParams.set("limit", String(limit)); | ||
| if (typeof projectId === "number") { | ||
| url.searchParams.set("project_id", `eq.${projectId}`); | ||
| } | ||
| if (status) { | ||
| url.searchParams.set("status", `eq.${status}`); | ||
| } | ||
| if (beforeDate) { | ||
| url.searchParams.set("created_at", `lt.${beforeDate}`); | ||
| } | ||
| if (typeof beforeId === "number") { | ||
| url.searchParams.set("id", `lt.${beforeId}`); | ||
| } | ||
| const headers: Record<string, string> = { | ||
| "access-token": apiKey, | ||
| "Prefer": "return=representation", | ||
| "Content-Type": "application/json", | ||
| "Connection": "close", | ||
| }; | ||
| if (debug) { | ||
| const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) }; | ||
| console.log(`Debug: Resolved API base URL: ${base}`); | ||
| console.log(`Debug: GET URL: ${url.toString()}`); | ||
| console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); | ||
| } | ||
| const response = await fetch(url.toString(), { method: "GET", headers }); | ||
| if (debug) { | ||
| console.log(`Debug: Response status: ${response.status}`); | ||
| } | ||
| const data = await response.text(); | ||
| if (response.ok) { | ||
| try { | ||
| return JSON.parse(data) as CheckupReport[]; | ||
| } catch { | ||
| throw new Error(`Failed to parse reports response: ${data}`); | ||
| } | ||
| } else { | ||
| throw new Error(formatHttpError("Failed to fetch reports", response.status, data)); | ||
| } | ||
| } | ||
| const MAX_ALL_REPORTS = 10000; | ||
| export async function fetchAllReports(params: Omit<FetchReportsParams, "beforeId" | "beforeDate">): Promise<CheckupReport[]> { | ||
| const pageSize = params.limit ?? 100; | ||
| const all: CheckupReport[] = []; | ||
| let beforeId: number | undefined; | ||
| while (true) { | ||
| const page = await fetchReports({ ...params, limit: pageSize, beforeId }); | ||
| if (page.length === 0) break; | ||
| all.push(...page); | ||
| if (all.length >= MAX_ALL_REPORTS) { | ||
| console.warn(`Warning: reached maximum of ${MAX_ALL_REPORTS} reports, stopping pagination`); | ||
| break; | ||
| } | ||
| beforeId = page[page.length - 1].id; | ||
| if (page.length < pageSize) break; | ||
| } | ||
| return all; | ||
| } | ||
| export async function fetchReportFiles(params: FetchReportFilesParams): Promise<CheckupReportFile[]> { | ||
| const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params; | ||
| if (!apiKey) { | ||
| throw new Error("API key is required"); | ||
| } | ||
| if (reportId === undefined && !checkId) { | ||
| throw new Error("Either reportId or checkId is required"); | ||
| } | ||
| const base = normalizeBaseUrl(apiBaseUrl); | ||
| const url = new URL(`${base}/checkup_report_files`); | ||
| if (typeof reportId === "number") { | ||
| url.searchParams.set("checkup_report_id", `eq.${reportId}`); | ||
| } | ||
| url.searchParams.set("order", "id.asc"); | ||
| if (type) { | ||
| url.searchParams.set("type", `eq.${type}`); | ||
| } | ||
| if (checkId) { | ||
| url.searchParams.set("check_id", `eq.${checkId}`); | ||
| } | ||
| const headers: Record<string, string> = { | ||
| "access-token": apiKey, | ||
| "Prefer": "return=representation", | ||
| "Content-Type": "application/json", | ||
| "Connection": "close", | ||
| }; | ||
| if (debug) { | ||
| const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) }; | ||
| console.log(`Debug: Resolved API base URL: ${base}`); | ||
| console.log(`Debug: GET URL: ${url.toString()}`); | ||
| console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); | ||
| } | ||
| const response = await fetch(url.toString(), { method: "GET", headers }); | ||
| if (debug) { | ||
| console.log(`Debug: Response status: ${response.status}`); | ||
| } | ||
| const data = await response.text(); | ||
| if (response.ok) { | ||
| try { | ||
| return JSON.parse(data) as CheckupReportFile[]; | ||
| } catch { | ||
| throw new Error(`Failed to parse report files response: ${data}`); | ||
| } | ||
| } else { | ||
| throw new Error(formatHttpError("Failed to fetch report files", response.status, data)); | ||
| } | ||
| } | ||
| export async function fetchReportFileData(params: FetchReportFileDataParams): Promise<CheckupReportFileData[]> { | ||
| const { apiKey, apiBaseUrl, reportId, type, checkId, debug } = params; | ||
| if (!apiKey) { | ||
| throw new Error("API key is required"); | ||
| } | ||
| if (reportId === undefined && !checkId) { | ||
| throw new Error("Either reportId or checkId is required"); | ||
| } | ||
| const base = normalizeBaseUrl(apiBaseUrl); | ||
| const url = new URL(`${base}/checkup_report_file_data`); | ||
| if (typeof reportId === "number") { | ||
| url.searchParams.set("checkup_report_id", `eq.${reportId}`); | ||
| } | ||
| url.searchParams.set("order", "id.asc"); | ||
| if (type) { | ||
| url.searchParams.set("type", `eq.${type}`); | ||
| } | ||
| if (checkId) { | ||
| url.searchParams.set("check_id", `eq.${checkId}`); | ||
| } | ||
| const headers: Record<string, string> = { | ||
| "access-token": apiKey, | ||
| "Prefer": "return=representation", | ||
| "Content-Type": "application/json", | ||
| "Connection": "close", | ||
| }; | ||
| if (debug) { | ||
| const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) }; | ||
| console.log(`Debug: Resolved API base URL: ${base}`); | ||
| console.log(`Debug: GET URL: ${url.toString()}`); | ||
| console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`); | ||
| } | ||
| const response = await fetch(url.toString(), { method: "GET", headers }); | ||
| if (debug) { | ||
| console.log(`Debug: Response status: ${response.status}`); | ||
| } | ||
| const data = await response.text(); | ||
| if (response.ok) { | ||
| try { | ||
| return JSON.parse(data) as CheckupReportFileData[]; | ||
| } catch { | ||
| throw new Error(`Failed to parse report file data response: ${data}`); | ||
| } | ||
| } else { | ||
| throw new Error(formatHttpError("Failed to fetch report file data", response.status, data)); | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // Lightweight markdown terminal renderer | ||
| // ============================================================================ | ||
| export function renderMarkdownForTerminal(md: string): string { | ||
| if (!md) return ""; | ||
| const RESET = "\x1b[0m"; | ||
| const BOLD = "\x1b[1m"; | ||
| const BOLD_UNDERLINE = "\x1b[1;4m"; | ||
| const DIM = "\x1b[2m"; | ||
| const ITALIC = "\x1b[3m"; | ||
| const CYAN = "\x1b[36m"; | ||
| const lines = md.split("\n"); | ||
| const output: string[] = []; | ||
| let inCodeBlock = false; | ||
| for (const line of lines) { | ||
| // Code block toggle | ||
| if (line.trimStart().startsWith("```")) { | ||
| inCodeBlock = !inCodeBlock; | ||
| if (inCodeBlock) { | ||
| output.push(`${DIM}${"─".repeat(40)}${RESET}`); | ||
| } else { | ||
| output.push(`${DIM}${"─".repeat(40)}${RESET}`); | ||
| } | ||
| continue; | ||
| } | ||
| // Inside code block — dim output | ||
| if (inCodeBlock) { | ||
| output.push(`${DIM} ${line}${RESET}`); | ||
| continue; | ||
| } | ||
| // Horizontal rule | ||
| if (/^-{3,}$/.test(line.trim()) || /^\*{3,}$/.test(line.trim()) || /^_{3,}$/.test(line.trim())) { | ||
| output.push(`${DIM}${"─".repeat(60)}${RESET}`); | ||
| continue; | ||
| } | ||
| // Headings | ||
| const headingMatch = line.match(/^(#{1,6})\s+(.*)/); | ||
| if (headingMatch) { | ||
| const level = headingMatch[1].length; | ||
| const text = headingMatch[2]; | ||
| if (level === 1) { | ||
| output.push(`${BOLD_UNDERLINE}${text}${RESET}`); | ||
| } else { | ||
| output.push(`${BOLD}${text}${RESET}`); | ||
| } | ||
| continue; | ||
| } | ||
| // Inline formatting | ||
| let formatted = line; | ||
| // Bold: **text** or __text__ | ||
| formatted = formatted.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`); | ||
| formatted = formatted.replace(/__(.+?)__/g, `${BOLD}$1${RESET}`); | ||
| // Italic: *text* (only single, not inside **) | ||
| formatted = formatted.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `${ITALIC}$1${RESET}`); | ||
| // Italic: _text_ — only at word boundaries (not inside identifiers like foo_bar_baz) | ||
| formatted = formatted.replace(/(?<=^|[\s(])_([^\s_](?:.*?[^\s_])?)_(?=$|[\s),.:;!?])/g, `${ITALIC}$1${RESET}`); | ||
| // Inline code: `text` | ||
| formatted = formatted.replace(/`([^`]+)`/g, `${CYAN}$1${RESET}`); | ||
| output.push(formatted); | ||
| } | ||
| return output.join("\n"); | ||
| } |
| import { describe, test, expect } from "bun:test"; | ||
| import { resolve } from "path"; | ||
| import { mkdtempSync, existsSync, readFileSync } from "fs"; | ||
| import { tmpdir } from "os"; | ||
| function runCli(args: string[], env: Record<string, string> = {}) { | ||
| const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts"); | ||
| const bunBin = | ||
| typeof process.execPath === "string" && process.execPath.length > 0 | ||
| ? process.execPath | ||
| : "bun"; | ||
| const result = Bun.spawnSync([bunBin, cliPath, ...args], { | ||
| env: { ...process.env, ...env }, | ||
| }); | ||
| return { | ||
| status: result.exitCode, | ||
| stdout: new TextDecoder().decode(result.stdout), | ||
| stderr: new TextDecoder().decode(result.stderr), | ||
| }; | ||
| } | ||
| async function runCliAsync( | ||
| args: string[], | ||
| env: Record<string, string> = {} | ||
| ) { | ||
| const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts"); | ||
| const bunBin = | ||
| typeof process.execPath === "string" && process.execPath.length > 0 | ||
| ? process.execPath | ||
| : "bun"; | ||
| const proc = Bun.spawn([bunBin, cliPath, ...args], { | ||
| env: { ...process.env, ...env }, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [status, stdout, stderr] = await Promise.all([ | ||
| proc.exited, | ||
| new Response(proc.stdout).text(), | ||
| new Response(proc.stderr).text(), | ||
| ]); | ||
| return { status, stdout, stderr }; | ||
| } | ||
| function isolatedEnv(extra: Record<string, string> = {}) { | ||
| const cfgHome = mkdtempSync(resolve(tmpdir(), "postgresai-cli-test-")); | ||
| return { | ||
| XDG_CONFIG_HOME: cfgHome, | ||
| HOME: cfgHome, | ||
| ...extra, | ||
| }; | ||
| } | ||
| async function startFakeApi() { | ||
| const requests: Array<{ | ||
| method: string; | ||
| pathname: string; | ||
| searchParams: Record<string, string>; | ||
| headers: Record<string, string>; | ||
| }> = []; | ||
| const server = Bun.serve({ | ||
| hostname: "127.0.0.1", | ||
| port: 0, | ||
| async fetch(req) { | ||
| const url = new URL(req.url); | ||
| const headers: Record<string, string> = {}; | ||
| for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v; | ||
| const searchParams: Record<string, string> = {}; | ||
| for (const [k, v] of url.searchParams.entries()) searchParams[k] = v; | ||
| requests.push({ | ||
| method: req.method, | ||
| pathname: url.pathname, | ||
| searchParams, | ||
| headers, | ||
| }); | ||
| // GET /checkup_reports | ||
| if ( | ||
| req.method === "GET" && | ||
| url.pathname.endsWith("/checkup_reports") | ||
| ) { | ||
| return new Response( | ||
| JSON.stringify([ | ||
| { | ||
| id: 1, | ||
| org_id: 1, | ||
| org_name: "TestOrg", | ||
| project_id: 10, | ||
| project_name: "TestProj", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| epoch: 1735689600, | ||
| status: "completed", | ||
| }, | ||
| ]), | ||
| { status: 200, headers: { "Content-Type": "application/json" } } | ||
| ); | ||
| } | ||
| // GET /checkup_report_files | ||
| if ( | ||
| req.method === "GET" && | ||
| url.pathname.endsWith("/checkup_report_files") | ||
| ) { | ||
| return new Response( | ||
| JSON.stringify([ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 1, | ||
| filename: "H002.json", | ||
| check_id: "H002", | ||
| type: "json", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "TestProj", | ||
| }, | ||
| { | ||
| id: 101, | ||
| checkup_report_id: 1, | ||
| filename: "H002.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "TestProj", | ||
| }, | ||
| ]), | ||
| { status: 200, headers: { "Content-Type": "application/json" } } | ||
| ); | ||
| } | ||
| // GET /checkup_report_file_data | ||
| if ( | ||
| req.method === "GET" && | ||
| url.pathname.endsWith("/checkup_report_file_data") | ||
| ) { | ||
| return new Response( | ||
| JSON.stringify([ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 1, | ||
| filename: "H002.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "TestProj", | ||
| data: "# H002 Report\n\nUnused indexes found.\n", | ||
| }, | ||
| ]), | ||
| { status: 200, headers: { "Content-Type": "application/json" } } | ||
| ); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }, | ||
| }); | ||
| return { | ||
| baseUrl: `http://${server.hostname}:${server.port}/api/general`, | ||
| requests, | ||
| stop: () => server.stop(true), | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Help output | ||
| // --------------------------------------------------------------------------- | ||
| describe("CLI reports command group", () => { | ||
| test("reports help exposes list, files, data subcommands", () => { | ||
| const r = runCli(["reports", "--help"], isolatedEnv()); | ||
| expect(r.status).toBe(0); | ||
| const out = `${r.stdout}\n${r.stderr}`; | ||
| expect(out).toContain("list"); | ||
| expect(out).toContain("files"); | ||
| expect(out).toContain("data"); | ||
| }); | ||
| // ----------------------------------------------------------------------- | ||
| // Input validation | ||
| // ----------------------------------------------------------------------- | ||
| test("reports list fails fast when API key is missing", () => { | ||
| const r = runCli(["reports", "list"], isolatedEnv()); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required"); | ||
| }); | ||
| test("reports files fails fast when API key is missing", () => { | ||
| const r = runCli(["reports", "files", "1"], isolatedEnv()); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required"); | ||
| }); | ||
| test("reports files fails when reportId is not a number", () => { | ||
| const r = runCli( | ||
| ["reports", "files", "abc"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("reportId must be a number"); | ||
| }); | ||
| test("reports files fails when neither reportId nor --check-id is provided", () => { | ||
| const r = runCli( | ||
| ["reports", "files"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("Either reportId or --check-id is required"); | ||
| }); | ||
| test("reports data fails fast when API key is missing", () => { | ||
| const r = runCli(["reports", "data", "1"], isolatedEnv()); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required"); | ||
| }); | ||
| test("reports data fails when reportId is not a number", () => { | ||
| const r = runCli( | ||
| ["reports", "data", "abc"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("reportId must be a number"); | ||
| }); | ||
| test("reports data fails when neither reportId nor --check-id is provided", () => { | ||
| const r = runCli( | ||
| ["reports", "data"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("Either reportId or --check-id is required"); | ||
| }); | ||
| // ----------------------------------------------------------------------- | ||
| // Successful API calls | ||
| // ----------------------------------------------------------------------- | ||
| test("reports list succeeds against a fake API", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "list"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const out = JSON.parse(r.stdout.trim()); | ||
| expect(Array.isArray(out)).toBe(true); | ||
| expect(out[0].id).toBe(1); | ||
| expect(out[0].status).toBe("completed"); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_reports") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.headers["access-token"]).toBe("test-key"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports list passes correct filters to API", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| [ | ||
| "reports", | ||
| "list", | ||
| "--project-id", | ||
| "10", | ||
| "--status", | ||
| "completed", | ||
| "--limit", | ||
| "5", | ||
| ], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_reports") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.project_id).toBe("eq.10"); | ||
| expect(req!.searchParams.status).toBe("eq.completed"); | ||
| expect(req!.searchParams.limit).toBe("5"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports files succeeds against a fake API", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "files", "1"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const out = JSON.parse(r.stdout.trim()); | ||
| expect(Array.isArray(out)).toBe(true); | ||
| expect(out[0].filename).toBe("H002.json"); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_files") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.checkup_report_id).toBe("eq.1"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports files passes type and check-id filters", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| ["reports", "files", "1", "--type", "md", "--check-id", "H002"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_files") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.type).toBe("eq.md"); | ||
| expect(req!.searchParams.check_id).toBe("eq.H002"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data outputs raw markdown by default", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| expect(r.stdout).toContain("# H002 Report"); | ||
| expect(r.stdout).toContain("Unused indexes found."); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data succeeds against a fake API with --json", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1", "--json"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const out = JSON.parse(r.stdout.trim()); | ||
| expect(Array.isArray(out)).toBe(true); | ||
| expect(out[0].data).toContain("# H002 Report"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports files succeeds with only --check-id (no reportId)", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "files", "--check-id", "H002"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_files") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.check_id).toBe("eq.H002"); | ||
| expect(req!.searchParams.checkup_report_id).toBeUndefined(); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data succeeds with only --check-id (no reportId)", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "--check-id", "H002", "--json"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_file_data") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.check_id).toBe("eq.H002"); | ||
| expect(req!.searchParams.checkup_report_id).toBeUndefined(); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data --output saves files to directory", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const outDir = mkdtempSync(resolve(tmpdir(), "pgai-output-test-")); | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1", "--output", outDir], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| // Should print the file path to stdout | ||
| expect(r.stdout).toContain("H002.md"); | ||
| // File should exist with correct content | ||
| const filePath = resolve(outDir, "H002.md"); | ||
| expect(existsSync(filePath)).toBe(true); | ||
| const content = readFileSync(filePath, "utf-8"); | ||
| expect(content).toContain("# H002 Report"); | ||
| expect(content).toContain("Unused indexes found."); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data -o creates directory if it does not exist", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const base = mkdtempSync(resolve(tmpdir(), "pgai-output-test-")); | ||
| const outDir = resolve(base, "nested", "dir"); | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1", "-o", outDir], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| expect(existsSync(resolve(outDir, "H002.md"))).toBe(true); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data --formatted is accepted", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1", "--formatted"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| // The command should succeed — ANSI formatting only active in TTY | ||
| // In non-TTY (our test pipe), it falls back to raw output | ||
| expect(r.stdout).toContain("H002 Report"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data sends correct filters to API", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| [ | ||
| "reports", | ||
| "data", | ||
| "1", | ||
| "--type", | ||
| "md", | ||
| "--check-id", | ||
| "H001", | ||
| "--json", | ||
| ], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_file_data") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.checkup_report_id).toBe("eq.1"); | ||
| expect(req!.searchParams.type).toBe("eq.md"); | ||
| expect(req!.searchParams.check_id).toBe("eq.H001"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports list --before filters by created_at with ISO date", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| ["reports", "list", "--before", "2025-01-15"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_reports") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.created_at).toContain("lt.2025-01-15"); | ||
| expect(req!.searchParams.order).toBe("id.desc"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports list --before accepts DD.MM.YYYY format", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| ["reports", "list", "--before", "15.01.2025"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_reports") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.created_at).toContain("lt.2025-01-15"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports list --before rejects invalid date", async () => { | ||
| const r = runCli( | ||
| ["reports", "list", "--before", "not-a-date"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("Unrecognized date format"); | ||
| }); | ||
| test("reports list --all --before rejects conflicting flags", () => { | ||
| const r = runCli( | ||
| ["reports", "list", "--all", "--before", "2025-01-15"], | ||
| isolatedEnv({ PGAI_API_KEY: "test-key" }) | ||
| ); | ||
| expect(r.status).toBe(1); | ||
| expect(`${r.stdout}\n${r.stderr}`).toContain("--all and --before cannot be used together"); | ||
| }); | ||
| test("reports data without --type defaults to md for terminal output", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| ["reports", "data", "1"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_file_data") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.type).toBe("eq.md"); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data --json without --type fetches all types", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| await runCliAsync( | ||
| ["reports", "data", "1", "--json"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| const req = api.requests.find((x) => | ||
| x.pathname.endsWith("/checkup_report_file_data") | ||
| ); | ||
| expect(req).toBeTruthy(); | ||
| expect(req!.searchParams.type).toBeUndefined(); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| test("reports data --output strips path traversal from filenames", async () => { | ||
| // Start a fake API that returns a filename with path traversal | ||
| const traversalRequests: typeof Array.prototype = []; | ||
| const server = Bun.serve({ | ||
| hostname: "127.0.0.1", | ||
| port: 0, | ||
| async fetch(req) { | ||
| const url = new URL(req.url); | ||
| if (url.pathname.endsWith("/checkup_report_file_data")) { | ||
| return new Response( | ||
| JSON.stringify([ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 1, | ||
| filename: "../../etc/malicious.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "TestProj", | ||
| data: "# Malicious content\n", | ||
| }, | ||
| ]), | ||
| { status: 200, headers: { "Content-Type": "application/json" } } | ||
| ); | ||
| } | ||
| return new Response("not found", { status: 404 }); | ||
| }, | ||
| }); | ||
| try { | ||
| const outDir = mkdtempSync(resolve(tmpdir(), "pgai-traversal-test-")); | ||
| const r = await runCliAsync( | ||
| ["reports", "data", "1", "--output", outDir], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: `http://${server.hostname}:${server.port}/api/general`, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| // File should be saved as basename only, not the traversal path | ||
| expect(existsSync(resolve(outDir, "malicious.md"))).toBe(true); | ||
| // Traversal path should NOT exist | ||
| expect(existsSync(resolve(outDir, "..", "..", "etc", "malicious.md"))).toBe(false); | ||
| // Stdout should show the safe name | ||
| expect(r.stdout).toContain("malicious.md"); | ||
| expect(r.stdout).not.toContain("../../"); | ||
| } finally { | ||
| server.stop(true); | ||
| } | ||
| }); | ||
| test("reports list --all fetches all pages", async () => { | ||
| const api = await startFakeApi(); | ||
| try { | ||
| const r = await runCliAsync( | ||
| ["reports", "list", "--all"], | ||
| isolatedEnv({ | ||
| PGAI_API_KEY: "test-key", | ||
| PGAI_API_BASE_URL: api.baseUrl, | ||
| }) | ||
| ); | ||
| expect(r.status).toBe(0); | ||
| const out = JSON.parse(r.stdout.trim()); | ||
| expect(Array.isArray(out)).toBe(true); | ||
| // The fake API always returns the same 1-item array, so --all will get 1 item | ||
| // (the page size > result count triggers stop) | ||
| expect(out.length).toBeGreaterThanOrEqual(1); | ||
| } finally { | ||
| api.stop(); | ||
| } | ||
| }); | ||
| }); |
| import { describe, test, expect, mock, afterEach, spyOn } from "bun:test"; | ||
| import { | ||
| fetchReports, | ||
| fetchAllReports, | ||
| fetchReportFiles, | ||
| fetchReportFileData, | ||
| renderMarkdownForTerminal, | ||
| parseFlexibleDate, | ||
| } from "../lib/reports"; | ||
| const originalFetch = globalThis.fetch; | ||
| // --------------------------------------------------------------------------- | ||
| // fetchReports | ||
| // --------------------------------------------------------------------------- | ||
| describe("fetchReports", () => { | ||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
| test("throws when apiKey is missing", async () => { | ||
| await expect( | ||
| fetchReports({ apiKey: "", apiBaseUrl: "https://api.example.com" }) | ||
| ).rejects.toThrow("API key is required"); | ||
| }); | ||
| test("constructs correct URL with no filters", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.pathname).toBe("/checkup_reports"); | ||
| expect(url.searchParams.get("order")).toBe("id.desc"); | ||
| expect(url.searchParams.get("limit")).toBe("20"); | ||
| expect(url.searchParams.has("project_id")).toBe(false); | ||
| expect(url.searchParams.has("status")).toBe(false); | ||
| }); | ||
| test("constructs correct URL with all filters", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| projectId: 5, | ||
| status: "completed", | ||
| limit: 10, | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.get("project_id")).toBe("eq.5"); | ||
| expect(url.searchParams.get("status")).toBe("eq.completed"); | ||
| expect(url.searchParams.get("limit")).toBe("10"); | ||
| }); | ||
| test("sends correct headers", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }); | ||
| const headers = capturedRequest!.options.headers as Record<string, string>; | ||
| expect(headers["access-token"]).toBe("test-key"); | ||
| expect(headers["Prefer"]).toBe("return=representation"); | ||
| expect(headers["Content-Type"]).toBe("application/json"); | ||
| expect(headers["Connection"]).toBe("close"); | ||
| }); | ||
| test("returns parsed response array", async () => { | ||
| const mockData = [ | ||
| { | ||
| id: 1, | ||
| org_id: 1, | ||
| org_name: "TestOrg", | ||
| project_id: 10, | ||
| project_name: "prod-db", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| epoch: 1735689600, | ||
| status: "completed", | ||
| }, | ||
| ]; | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockData), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const result = await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }); | ||
| expect(result).toEqual(mockData); | ||
| expect(result[0].id).toBe(1); | ||
| expect(result[0].status).toBe("completed"); | ||
| }); | ||
| test("throws formatted error on non-200 response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response('{"message": "Unauthorized"}', { | ||
| status: 401, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReports({ | ||
| apiKey: "invalid-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }) | ||
| ).rejects.toThrow(/Failed to fetch reports/); | ||
| }); | ||
| test("sets created_at filter when beforeDate is provided", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| beforeDate: "2025-01-15T00:00:00.000Z", | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.get("created_at")).toBe("lt.2025-01-15T00:00:00.000Z"); | ||
| expect(url.searchParams.get("order")).toBe("id.desc"); | ||
| }); | ||
| test("sets id=lt.beforeId when beforeId is provided (internal pagination)", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| beforeId: 50, | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.get("id")).toBe("lt.50"); | ||
| }); | ||
| test("logs debug info when debug is true", async () => { | ||
| const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| debug: true, | ||
| }); | ||
| const calls = consoleSpy.mock.calls.map((c) => c[0]); | ||
| expect(calls.some((c: string) => c.includes("Debug: Resolved API base URL"))).toBe(true); | ||
| expect(calls.some((c: string) => c.includes("Debug: GET URL"))).toBe(true); | ||
| expect(calls.some((c: string) => c.includes("Debug: Request headers"))).toBe(true); | ||
| expect(calls.some((c: string) => c.includes("Debug: Response status"))).toBe(true); | ||
| consoleSpy.mockRestore(); | ||
| }); | ||
| test("throws on invalid JSON response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response("not valid json", { | ||
| status: 200, | ||
| headers: { "Content-Type": "text/plain" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }) | ||
| ).rejects.toThrow(/Failed to parse reports response/); | ||
| }); | ||
| test("does not set id or created_at params when no before options provided", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.has("id")).toBe(false); | ||
| expect(url.searchParams.has("created_at")).toBe(false); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // fetchAllReports | ||
| // --------------------------------------------------------------------------- | ||
| describe("fetchAllReports", () => { | ||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
| test("fetches all pages until empty response", async () => { | ||
| const page1 = [ | ||
| { id: 100, org_id: 1, org_name: "O", project_id: 1, project_name: "P", created_at: "", created_formatted: "", epoch: 0, status: "completed" }, | ||
| { id: 90, org_id: 1, org_name: "O", project_id: 1, project_name: "P", created_at: "", created_formatted: "", epoch: 0, status: "completed" }, | ||
| ]; | ||
| const page2 = [ | ||
| { id: 80, org_id: 1, org_name: "O", project_id: 1, project_name: "P", created_at: "", created_formatted: "", epoch: 0, status: "completed" }, | ||
| ]; | ||
| let callCount = 0; | ||
| globalThis.fetch = mock((url: string) => { | ||
| callCount++; | ||
| const u = new URL(url); | ||
| const idParam = u.searchParams.get("id"); | ||
| let data; | ||
| if (!idParam) { | ||
| data = page1; | ||
| } else if (idParam === "lt.90") { | ||
| data = page2; | ||
| } else { | ||
| data = []; | ||
| } | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(data), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const result = await fetchAllReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| limit: 2, | ||
| }); | ||
| expect(result.length).toBe(3); | ||
| expect(result[0].id).toBe(100); | ||
| expect(result[1].id).toBe(90); | ||
| expect(result[2].id).toBe(80); | ||
| }); | ||
| test("returns empty array when no reports exist", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const result = await fetchAllReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| test("stops at MAX_ALL_REPORTS cap (10000)", async () => { | ||
| const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); | ||
| const pageSize = 500; | ||
| let callCount = 0; | ||
| globalThis.fetch = mock((url: string) => { | ||
| callCount++; | ||
| const u = new URL(url); | ||
| const idParam = u.searchParams.get("id"); | ||
| // Generate a full page each time with descending IDs | ||
| const startId = idParam ? parseInt(idParam.replace("lt.", "")) - 1 : 100000; | ||
| const page = Array.from({ length: pageSize }, (_, i) => ({ | ||
| id: startId - i, | ||
| org_id: 1, | ||
| org_name: "O", | ||
| project_id: 1, | ||
| project_name: "P", | ||
| created_at: "", | ||
| created_formatted: "", | ||
| epoch: 0, | ||
| status: "completed", | ||
| })); | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(page), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const result = await fetchAllReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| limit: pageSize, | ||
| }); | ||
| // Should stop at 10000 (MAX_ALL_REPORTS) | ||
| expect(result.length).toBe(10000); | ||
| // Should have logged a warning | ||
| const warnCalls = warnSpy.mock.calls.map((c) => c[0]); | ||
| expect(warnCalls.some((c: string) => c.includes("maximum of 10000"))).toBe(true); | ||
| // Should have made exactly 20 calls (10000 / 500) | ||
| expect(callCount).toBe(20); | ||
| warnSpy.mockRestore(); | ||
| }); | ||
| test("stops when page has fewer items than limit", async () => { | ||
| const page = [ | ||
| { id: 50, org_id: 1, org_name: "O", project_id: 1, project_name: "P", created_at: "", created_formatted: "", epoch: 0, status: "completed" }, | ||
| ]; | ||
| let callCount = 0; | ||
| globalThis.fetch = mock(() => { | ||
| callCount++; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(page), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const result = await fetchAllReports({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| limit: 10, | ||
| }); | ||
| expect(result.length).toBe(1); | ||
| expect(callCount).toBe(1); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // fetchReportFiles | ||
| // --------------------------------------------------------------------------- | ||
| describe("fetchReportFiles", () => { | ||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
| test("throws when apiKey is missing", async () => { | ||
| await expect( | ||
| fetchReportFiles({ | ||
| apiKey: "", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| }) | ||
| ).rejects.toThrow("API key is required"); | ||
| }); | ||
| test("throws when neither reportId nor checkId is provided", async () => { | ||
| await expect( | ||
| fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }) | ||
| ).rejects.toThrow("Either reportId or checkId is required"); | ||
| }); | ||
| test("works with only checkId (no reportId)", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| checkId: "H002", | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.pathname).toBe("/checkup_report_files"); | ||
| expect(url.searchParams.has("checkup_report_id")).toBe(false); | ||
| expect(url.searchParams.get("check_id")).toBe("eq.H002"); | ||
| expect(url.searchParams.get("order")).toBe("id.asc"); | ||
| }); | ||
| test("constructs URL with required checkup_report_id", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 42, | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.pathname).toBe("/checkup_report_files"); | ||
| expect(url.searchParams.get("checkup_report_id")).toBe("eq.42"); | ||
| expect(url.searchParams.get("order")).toBe("id.asc"); | ||
| expect(url.searchParams.has("type")).toBe(false); | ||
| expect(url.searchParams.has("check_id")).toBe(false); | ||
| }); | ||
| test("applies optional type and check_id filters", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 42, | ||
| type: "md", | ||
| checkId: "H002", | ||
| }); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.get("type")).toBe("eq.md"); | ||
| expect(url.searchParams.get("check_id")).toBe("eq.H002"); | ||
| }); | ||
| test("returns parsed response array", async () => { | ||
| const mockData = [ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 42, | ||
| filename: "H002.json", | ||
| check_id: "H002", | ||
| type: "json", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "prod-db", | ||
| }, | ||
| ]; | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockData), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const result = await fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 42, | ||
| }); | ||
| expect(result).toEqual(mockData); | ||
| expect(result[0].filename).toBe("H002.json"); | ||
| }); | ||
| test("throws on error response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response('{"message": "Not found"}', { | ||
| status: 404, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 999, | ||
| }) | ||
| ).rejects.toThrow(/Failed to fetch report files/); | ||
| }); | ||
| test("logs debug info when debug is true", async () => { | ||
| const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| debug: true, | ||
| }); | ||
| const calls = consoleSpy.mock.calls.map((c) => c[0]); | ||
| expect(calls.some((c: string) => c.includes("Debug: Resolved API base URL"))).toBe(true); | ||
| expect(calls.some((c: string) => c.includes("Debug: Response status"))).toBe(true); | ||
| consoleSpy.mockRestore(); | ||
| }); | ||
| test("throws on invalid JSON response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response("not valid json", { | ||
| status: 200, | ||
| headers: { "Content-Type": "text/plain" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReportFiles({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| }) | ||
| ).rejects.toThrow(/Failed to parse report files response/); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // fetchReportFileData | ||
| // --------------------------------------------------------------------------- | ||
| describe("fetchReportFileData", () => { | ||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
| test("throws when apiKey is missing", async () => { | ||
| await expect( | ||
| fetchReportFileData({ | ||
| apiKey: "", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| }) | ||
| ).rejects.toThrow("API key is required"); | ||
| }); | ||
| test("throws when neither reportId nor checkId is provided", async () => { | ||
| await expect( | ||
| fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| }) | ||
| ).rejects.toThrow("Either reportId or checkId is required"); | ||
| }); | ||
| test("works with only checkId (no reportId)", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| checkId: "H002", | ||
| }); | ||
| expect(capturedRequest).not.toBeNull(); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.pathname).toBe("/checkup_report_file_data"); | ||
| expect(url.searchParams.has("checkup_report_id")).toBe(false); | ||
| expect(url.searchParams.get("check_id")).toBe("eq.H002"); | ||
| expect(url.searchParams.get("order")).toBe("id.asc"); | ||
| }); | ||
| test("constructs correct URL with reportId", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 123, | ||
| }); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.pathname).toBe("/checkup_report_file_data"); | ||
| expect(url.searchParams.get("checkup_report_id")).toBe("eq.123"); | ||
| expect(url.searchParams.get("order")).toBe("id.asc"); | ||
| }); | ||
| test("applies optional filters", async () => { | ||
| let capturedRequest: { url: string; options: RequestInit } | null = null; | ||
| globalThis.fetch = mock((url: string, options: RequestInit) => { | ||
| capturedRequest = { url, options }; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 123, | ||
| type: "json", | ||
| checkId: "H001", | ||
| }); | ||
| const url = new URL(capturedRequest!.url); | ||
| expect(url.searchParams.get("type")).toBe("eq.json"); | ||
| expect(url.searchParams.get("check_id")).toBe("eq.H001"); | ||
| }); | ||
| test("returns parsed response with data field", async () => { | ||
| const mockData = [ | ||
| { | ||
| id: 200, | ||
| checkup_report_id: 123, | ||
| filename: "H002.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "prod-db", | ||
| data: "# H002 Report\n\nUnused indexes found.\n", | ||
| }, | ||
| { | ||
| id: 201, | ||
| checkup_report_id: 123, | ||
| filename: "H002.json", | ||
| check_id: "H002", | ||
| type: "json", | ||
| created_at: "2025-01-01T00:00:00Z", | ||
| created_formatted: "2025-01-01 00:00:00", | ||
| project_id: 10, | ||
| project_name: "prod-db", | ||
| data: '{"unused_indexes": []}', | ||
| }, | ||
| ]; | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockData), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const result = await fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 123, | ||
| }); | ||
| expect(result.length).toBe(2); | ||
| expect(result[0].data).toContain("# H002 Report"); | ||
| expect(result[1].data).toBe('{"unused_indexes": []}'); | ||
| }); | ||
| test("throws on error response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response('{"message": "Unauthorized"}', { | ||
| status: 401, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReportFileData({ | ||
| apiKey: "invalid-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 123, | ||
| }) | ||
| ).rejects.toThrow(/Failed to fetch report file data/); | ||
| }); | ||
| test("logs debug info when debug is true", async () => { | ||
| const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| debug: true, | ||
| }); | ||
| const calls = consoleSpy.mock.calls.map((c) => c[0]); | ||
| expect(calls.some((c: string) => c.includes("Debug: Resolved API base URL"))).toBe(true); | ||
| expect(calls.some((c: string) => c.includes("Debug: Response status"))).toBe(true); | ||
| consoleSpy.mockRestore(); | ||
| }); | ||
| test("throws on invalid JSON response", async () => { | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response("not valid json", { | ||
| status: 200, | ||
| headers: { "Content-Type": "text/plain" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| await expect( | ||
| fetchReportFileData({ | ||
| apiKey: "test-key", | ||
| apiBaseUrl: "https://api.example.com", | ||
| reportId: 1, | ||
| }) | ||
| ).rejects.toThrow(/Failed to parse report file data response/); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // renderMarkdownForTerminal | ||
| // --------------------------------------------------------------------------- | ||
| describe("renderMarkdownForTerminal", () => { | ||
| test("returns empty string for empty input", () => { | ||
| expect(renderMarkdownForTerminal("")).toBe(""); | ||
| }); | ||
| test("passes plain text through", () => { | ||
| const result = renderMarkdownForTerminal("Just plain text"); | ||
| expect(result).toContain("Just plain text"); | ||
| }); | ||
| test("renders # heading with bold+underline", () => { | ||
| const result = renderMarkdownForTerminal("# Hello World"); | ||
| expect(result).toContain("\x1b["); | ||
| expect(result).toContain("Hello World"); | ||
| }); | ||
| test("renders ## heading with bold", () => { | ||
| const result = renderMarkdownForTerminal("## Section Title"); | ||
| expect(result).toContain("\x1b[1m"); | ||
| expect(result).toContain("Section Title"); | ||
| }); | ||
| test("renders **bold** text", () => { | ||
| const result = renderMarkdownForTerminal("This is **bold** text"); | ||
| expect(result).toContain("\x1b[1m"); | ||
| expect(result).toContain("bold"); | ||
| }); | ||
| test("renders `inline code`", () => { | ||
| const result = renderMarkdownForTerminal("Run `SELECT 1`"); | ||
| expect(result).toContain("SELECT 1"); | ||
| }); | ||
| test("renders code blocks", () => { | ||
| const input = "```\nSELECT 1;\nSELECT 2;\n```"; | ||
| const result = renderMarkdownForTerminal(input); | ||
| expect(result).toContain("SELECT 1;"); | ||
| expect(result).toContain("SELECT 2;"); | ||
| }); | ||
| test("renders horizontal rules", () => { | ||
| const result = renderMarkdownForTerminal("---"); | ||
| expect(result.replace(/\x1b\[[0-9;]*m/g, "").trim().length).toBeGreaterThan(3); | ||
| }); | ||
| test("renders bullet lists", () => { | ||
| const result = renderMarkdownForTerminal("- item one\n- item two"); | ||
| expect(result).toContain("item one"); | ||
| expect(result).toContain("item two"); | ||
| }); | ||
| test("does not italicize underscores inside identifiers", () => { | ||
| const result = renderMarkdownForTerminal("goodvibes_local_monitoring_dev"); | ||
| // Strip ANSI codes and check the text is unchanged | ||
| const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); | ||
| expect(stripped).toBe("goodvibes_local_monitoring_dev"); | ||
| // Must NOT contain italic ANSI code | ||
| expect(result).not.toContain("\x1b[3m"); | ||
| }); | ||
| test("renders _word_ as italic at word boundaries", () => { | ||
| const result = renderMarkdownForTerminal("This is _unknown_ value"); | ||
| expect(result).toContain("\x1b[3m"); | ||
| expect(result).toContain("unknown"); | ||
| }); | ||
| test("renders *italic* with single asterisks", () => { | ||
| const result = renderMarkdownForTerminal("This is *italic* text"); | ||
| expect(result).toContain("\x1b[3m"); | ||
| expect(result).toContain("italic"); | ||
| }); | ||
| test("renders __bold__ with double underscores", () => { | ||
| const result = renderMarkdownForTerminal("This is __bold__ text"); | ||
| expect(result).toContain("\x1b[1m"); | ||
| expect(result).toContain("bold"); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // parseFlexibleDate | ||
| // --------------------------------------------------------------------------- | ||
| describe("parseFlexibleDate", () => { | ||
| test("parses YYYY-MM-DD", () => { | ||
| expect(parseFlexibleDate("2025-01-15")).toBe("2025-01-15T00:00:00.000Z"); | ||
| }); | ||
| test("parses YYYY-MM-DDTHH:mm:ss", () => { | ||
| expect(parseFlexibleDate("2025-01-15T10:30:00")).toBe("2025-01-15T10:30:00.000Z"); | ||
| }); | ||
| test("parses YYYY-MM-DD HH:mm:ss", () => { | ||
| expect(parseFlexibleDate("2025-01-15 10:30:00")).toBe("2025-01-15T10:30:00.000Z"); | ||
| }); | ||
| test("parses YYYY-MM-DD HH:mm", () => { | ||
| expect(parseFlexibleDate("2025-01-15 10:30")).toBe("2025-01-15T10:30:00.000Z"); | ||
| }); | ||
| test("parses DD.MM.YYYY", () => { | ||
| expect(parseFlexibleDate("15.01.2025")).toBe("2025-01-15T00:00:00.000Z"); | ||
| }); | ||
| test("parses DD.MM.YYYY HH:mm", () => { | ||
| expect(parseFlexibleDate("15.01.2025 10:30")).toBe("2025-01-15T10:30:00.000Z"); | ||
| }); | ||
| test("parses DD.MM.YYYY HH:mm:ss", () => { | ||
| expect(parseFlexibleDate("15.01.2025 10:30:45")).toBe("2025-01-15T10:30:45.000Z"); | ||
| }); | ||
| test("parses D.M.YYYY (single-digit day/month)", () => { | ||
| expect(parseFlexibleDate("5.1.2025")).toBe("2025-01-05T00:00:00.000Z"); | ||
| }); | ||
| test("trims whitespace", () => { | ||
| expect(parseFlexibleDate(" 2025-01-15 ")).toBe("2025-01-15T00:00:00.000Z"); | ||
| }); | ||
| test("throws on unrecognized format", () => { | ||
| expect(() => parseFlexibleDate("Jan 15, 2025")).toThrow(/Unrecognized date format/); | ||
| }); | ||
| test("throws on invalid date values", () => { | ||
| expect(() => parseFlexibleDate("2025-13-45")).toThrow(/Invalid date/); | ||
| }); | ||
| test("throws on invalid DD.MM.YYYY values", () => { | ||
| expect(() => parseFlexibleDate("45.13.2025")).toThrow(/Invalid date/); | ||
| }); | ||
| }); |
+90
-0
@@ -17,2 +17,3 @@ import pkg from "../package.json"; | ||
| } from "./issues"; | ||
| import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, parseFlexibleDate } from "./reports"; | ||
| import { resolveBaseUrls } from "./util"; | ||
@@ -254,2 +255,46 @@ | ||
| // Reports Tools | ||
| if (toolName === "list_reports") { | ||
| const projectId = args.project_id !== undefined ? Number(args.project_id) : undefined; | ||
| const status = args.status ? String(args.status) : undefined; | ||
| const limit = args.limit !== undefined ? Number(args.limit) : undefined; | ||
| const beforeDate = args.before_date ? parseFlexibleDate(String(args.before_date)) : undefined; | ||
| const all = args.all === true; | ||
| let reports; | ||
| if (all) { | ||
| reports = await fetchAllReports({ apiKey, apiBaseUrl, projectId, status, limit, debug }); | ||
| } else { | ||
| reports = await fetchReports({ apiKey, apiBaseUrl, projectId, status, limit, beforeDate, debug }); | ||
| } | ||
| return { content: [{ type: "text", text: JSON.stringify(reports, null, 2) }] }; | ||
| } | ||
| if (toolName === "list_report_files") { | ||
| const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined; | ||
| if (reportId !== undefined && isNaN(reportId)) { | ||
| return { content: [{ type: "text", text: "report_id must be a number" }], isError: true }; | ||
| } | ||
| const type = args.type ? String(args.type) as "json" | "md" : undefined; | ||
| const checkId = args.check_id ? String(args.check_id) : undefined; | ||
| if (reportId === undefined && !checkId) { | ||
| return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true }; | ||
| } | ||
| const files = await fetchReportFiles({ apiKey, apiBaseUrl, reportId, type, checkId, debug }); | ||
| return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] }; | ||
| } | ||
| if (toolName === "get_report_data") { | ||
| const reportId = args.report_id !== undefined ? Number(args.report_id) : undefined; | ||
| if (reportId !== undefined && isNaN(reportId)) { | ||
| return { content: [{ type: "text", text: "report_id must be a number" }], isError: true }; | ||
| } | ||
| const type = args.type ? String(args.type) as "json" | "md" : undefined; | ||
| const checkId = args.check_id ? String(args.check_id) : undefined; | ||
| if (reportId === undefined && !checkId) { | ||
| return { content: [{ type: "text", text: "Either report_id or check_id is required" }], isError: true }; | ||
| } | ||
| const files = await fetchReportFileData({ apiKey, apiBaseUrl, reportId, type, checkId, debug }); | ||
| return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] }; | ||
| } | ||
| throw new Error(`Unknown tool: ${toolName}`); | ||
@@ -447,2 +492,47 @@ } catch (err) { | ||
| }, | ||
| // Reports Tools | ||
| { | ||
| name: "list_reports", | ||
| description: "List checkup reports. Returns report metadata: id, project, status, timestamps. Use get_report_data to fetch actual report content. Supports date-based filtering with before_date.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| project_id: { type: "number", description: "Filter by project ID" }, | ||
| status: { type: "string", description: "Filter by status (e.g., 'completed')" }, | ||
| limit: { type: "number", description: "Max number of reports to return (default: 20)" }, | ||
| before_date: { type: "string", description: "Show reports created before this date (YYYY-MM-DD, DD.MM.YYYY, YYYY-MM-DD HH:mm, etc.)" }, | ||
| all: { type: "boolean", description: "Fetch all reports (paginated automatically)" }, | ||
| debug: { type: "boolean", description: "Enable verbose debug logs" }, | ||
| }, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| { | ||
| name: "list_report_files", | ||
| description: "List files in a checkup report (metadata only, no content). Each report contains json (raw data) and md (markdown analysis) files per check. Either report_id or check_id must be provided.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" }, | ||
| type: { type: "string", description: "Filter by file type: 'json' or 'md'" }, | ||
| check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" }, | ||
| debug: { type: "boolean", description: "Enable verbose debug logs" }, | ||
| }, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| { | ||
| name: "get_report_data", | ||
| description: "Get checkup report file content. Returns files with a 'data' field containing the actual content: markdown analysis or JSON raw data. Use type='md' for human-readable analysis with recommendations, type='json' for raw check data. Either report_id or check_id must be provided.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| report_id: { type: "number", description: "Checkup report ID (optional if check_id is provided)" }, | ||
| type: { type: "string", description: "Filter by file type: 'json' for raw data, 'md' for markdown analysis" }, | ||
| check_id: { type: "string", description: "Filter by check ID (e.g., 'H002', 'F004')" }, | ||
| debug: { type: "boolean", description: "Enable verbose debug logs" }, | ||
| }, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| ], | ||
@@ -449,0 +539,0 @@ }; |
+1
-1
| { | ||
| "name": "postgresai", | ||
| "version": "0.15.0-dev.1", | ||
| "version": "0.15.0-dev.2", | ||
| "description": "postgres_ai CLI", | ||
@@ -5,0 +5,0 @@ "license": "Apache-2.0", |
+390
-0
@@ -1534,2 +1534,392 @@ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; | ||
| describe("list_reports tool", () => { | ||
| test("successfully returns reports list as JSON", async () => { | ||
| const mockReports = [ | ||
| { id: 1, org_id: 1, org_name: "TestOrg", project_id: 10, project_name: "prod-db", status: "completed" }, | ||
| ]; | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockReports), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("list_reports")); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed).toHaveLength(1); | ||
| expect(parsed[0].status).toBe("completed"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("passes filters to API", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await handleToolCall(createRequest("list_reports", { | ||
| project_id: 5, | ||
| status: "completed", | ||
| limit: 10, | ||
| })); | ||
| expect(capturedUrl).toContain("project_id=eq.5"); | ||
| expect(capturedUrl).toContain("status=eq.completed"); | ||
| expect(capturedUrl).toContain("limit=10"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("handles API errors gracefully", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response('{"message": "Unauthorized"}', { | ||
| status: 401, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("list_reports")); | ||
| expect(response.isError).toBe(true); | ||
| expect(getResponseText(response)).toContain("401"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("passes before_date to API as created_at filter", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await handleToolCall(createRequest("list_reports", { | ||
| before_date: "2025-01-15", | ||
| })); | ||
| expect(capturedUrl).toContain("created_at=lt.2025-01-15"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("fetches all reports when all=true", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| const mockReports = [ | ||
| { id: 10, org_id: 1, org_name: "O", project_id: 1, project_name: "P", status: "completed" }, | ||
| ]; | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockReports), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("list_reports", { | ||
| all: true, | ||
| })); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed).toHaveLength(1); | ||
| expect(parsed[0].id).toBe(10); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| describe("list_report_files tool", () => { | ||
| test("returns error when neither report_id nor check_id is provided", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| const response = await handleToolCall(createRequest("list_report_files", {})); | ||
| expect(response.isError).toBe(true); | ||
| expect(getResponseText(response)).toContain("Either report_id or check_id is required"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("works with only check_id (no report_id)", async () => { | ||
| const mockFiles = [ | ||
| { id: 100, checkup_report_id: 1, filename: "H002.md", check_id: "H002", type: "md" }, | ||
| ]; | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(mockFiles), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("list_report_files", { | ||
| check_id: "H002", | ||
| })); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed[0].filename).toBe("H002.md"); | ||
| expect(capturedUrl).toContain("check_id=eq.H002"); | ||
| expect(capturedUrl).not.toContain("checkup_report_id"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("successfully returns report files", async () => { | ||
| const mockFiles = [ | ||
| { id: 100, checkup_report_id: 1, filename: "H002.md", check_id: "H002", type: "md" }, | ||
| ]; | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(mockFiles), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("list_report_files", { | ||
| report_id: 1, | ||
| type: "md", | ||
| check_id: "H002", | ||
| })); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed[0].filename).toBe("H002.md"); | ||
| expect(capturedUrl).toContain("checkup_report_id=eq.1"); | ||
| expect(capturedUrl).toContain("type=eq.md"); | ||
| expect(capturedUrl).toContain("check_id=eq.H002"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| describe("get_report_data tool", () => { | ||
| test("returns error when neither report_id nor check_id is provided", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| const response = await handleToolCall(createRequest("get_report_data", {})); | ||
| expect(response.isError).toBe(true); | ||
| expect(getResponseText(response)).toContain("Either report_id or check_id is required"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("works with only check_id (no report_id)", async () => { | ||
| const mockData = [ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 1, | ||
| filename: "H002.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| data: "# H002\n\nUnused indexes found.", | ||
| }, | ||
| ]; | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify(mockData), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("get_report_data", { | ||
| check_id: "H002", | ||
| })); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed[0].data).toContain("# H002"); | ||
| expect(capturedUrl).toContain("check_id=eq.H002"); | ||
| expect(capturedUrl).not.toContain("checkup_report_id"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("successfully returns report data with content", async () => { | ||
| const mockData = [ | ||
| { | ||
| id: 100, | ||
| checkup_report_id: 1, | ||
| filename: "H002.md", | ||
| check_id: "H002", | ||
| type: "md", | ||
| data: "# H002\n\nUnused indexes found.", | ||
| }, | ||
| ]; | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| globalThis.fetch = mock(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(mockData), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ) | ||
| ) as unknown as typeof fetch; | ||
| const response = await handleToolCall(createRequest("get_report_data", { | ||
| report_id: 1, | ||
| type: "md", | ||
| check_id: "H002", | ||
| })); | ||
| expect(response.isError).toBeUndefined(); | ||
| const parsed = JSON.parse(getResponseText(response)); | ||
| expect(parsed[0].data).toContain("# H002"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| test("passes filters to API", async () => { | ||
| const readConfigSpy = spyOn(config, "readConfig").mockReturnValue({ | ||
| apiKey: "test-key", | ||
| baseUrl: null, | ||
| orgId: null, | ||
| defaultProject: null, | ||
| projectName: null, | ||
| }); | ||
| let capturedUrl: string | undefined; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve( | ||
| new Response(JSON.stringify([]), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }) | ||
| ); | ||
| }) as unknown as typeof fetch; | ||
| await handleToolCall(createRequest("get_report_data", { | ||
| report_id: 42, | ||
| type: "json", | ||
| check_id: "F004", | ||
| })); | ||
| expect(capturedUrl).toContain("checkup_report_id=eq.42"); | ||
| expect(capturedUrl).toContain("type=eq.json"); | ||
| expect(capturedUrl).toContain("check_id=eq.F004"); | ||
| readConfigSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| describe("error propagation", () => { | ||
@@ -1536,0 +1926,0 @@ test("propagates API errors through MCP layer", async () => { |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 21 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 21 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
2034063
6.04%69
4.55%52801
6.31%153
6.25%291
47.72%