From 3bc7ce5269fb9f89da93383bcfd8a9f865adccd5 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 9 Jul 2026 23:54:31 +0000 Subject: [PATCH] feat(per-instance-hook-scoping): scope observability + backup hooks by instance --- .../routers/backups.py | 11 ++- .../services/settings_store.py | 18 +++- backend/tests/test_backups.py | 83 ++++++++++++++++++ frontend/src/api/backups.ts | 11 ++- frontend/src/api/client.ts | 21 +++-- .../src/hooks/__tests__/useBackups.test.ts | 85 +++++++++++++++++++ frontend/src/hooks/useBackups.ts | 18 ++-- frontend/src/hooks/useObservability.ts | 18 ++-- frontend/src/pages/service-tabs/AlertsTab.tsx | 19 ++--- frontend/src/pages/service-tabs/JobsTab.tsx | 23 +++-- .../src/pages/service-tabs/MetricsTab.tsx | 15 +--- .../service-tabs/__tests__/AlertsTab.test.tsx | 19 ++++- 12 files changed, 268 insertions(+), 73 deletions(-) create mode 100644 frontend/src/hooks/__tests__/useBackups.test.ts diff --git a/backend/src/media_library_viewer_api/routers/backups.py b/backend/src/media_library_viewer_api/routers/backups.py index eec4dac..7ed7a54 100644 --- a/backend/src/media_library_viewer_api/routers/backups.py +++ b/backend/src/media_library_viewer_api/routers/backups.py @@ -129,9 +129,10 @@ def post_backup_start( @router.get("/jobs") def get_backup_jobs( + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[dict[str, Any]]: - jobs = store.list_backup_jobs() + jobs = store.list_backup_jobs(service_id=service_id) return jobs @@ -155,9 +156,10 @@ def get_backup_runs( job_id: str | None = None, status: str | None = None, limit: int = 50, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupRunResponse]: - runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit) + runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id) return [BackupRunResponse(**run) for run in runs] @@ -177,9 +179,12 @@ def get_backup_alerts( job_id: str | None = None, acknowledged: bool | None = None, severity: str | None = None, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupAlertResponse]: - alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity) + alerts = store.list_backup_alerts( + job_id=job_id, acknowledged=acknowledged, severity=severity, service_id=service_id, + ) return [BackupAlertResponse(**alert) for alert in alerts] diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index 2043fc9..d5f0ef0 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -1075,10 +1075,16 @@ class SettingsStore: row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone() return self._row_to_job(row) if row else None - def list_backup_jobs(self) -> list[dict[str, Any]]: + def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]: self.init_schema() + where = "" + params: list[Any] = [] + if service_id: + where = "WHERE service_id = ?" + params.append(service_id) + sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC" with self.connect() as conn: - rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall() + rows = conn.execute(sql, params).fetchall() return [self._row_to_job(row) for row in rows] # ------------------------------------------------------------------ @@ -1167,6 +1173,7 @@ class SettingsStore: job_id: str | None = None, status: str | None = None, limit: int = 50, + service_id: str | None = None, ) -> list[dict[str, Any]]: self.init_schema() clauses: list[str] = [] @@ -1177,6 +1184,9 @@ class SettingsStore: if status: clauses.append("status = ?") params.append(status) + if service_id: + clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)") + params.append(service_id) where = f"WHERE {' AND '.join(clauses)}" if clauses else "" sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?" params.append(max(1, min(int(limit), 200))) @@ -1257,6 +1267,7 @@ class SettingsStore: job_id: str | None = None, acknowledged: bool | None = None, severity: str | None = None, + service_id: str | None = None, ) -> list[dict[str, Any]]: self.init_schema() clauses: list[str] = [] @@ -1270,6 +1281,9 @@ class SettingsStore: if severity: clauses.append("severity = ?") params.append(severity) + if service_id: + clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)") + params.append(service_id) where = f"WHERE {' AND '.join(clauses)}" if clauses else "" sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC" with self.connect() as conn: diff --git a/backend/tests/test_backups.py b/backend/tests/test_backups.py index 134b7ae..06c8d14 100644 --- a/backend/tests/test_backups.py +++ b/backend/tests/test_backups.py @@ -1,6 +1,7 @@ import tempfile from pathlib import Path +import pytest from fastapi.testclient import TestClient from media_library_viewer_api.main import app @@ -33,6 +34,88 @@ def test_dashboard_backups(): auth_module._API_KEY = None +# --------------------------------------------------------------------------- +# Per-instance service scoping (PI-110, PI-111, PI-119) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store(tmp_path): + """Fresh SettingsStore with schema initialized.""" + db_path = tmp_path / "test_settings.sqlite" + s = SettingsStore(db_path) + s.init_schema() + return s + + +class TestBackupServiceScoping: + """Verify service_id filtering on list_backup_jobs/runs/alerts.""" + + def test_list_backup_jobs_filtered_by_service(self, store: SettingsStore): + store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + assert len(store.list_backup_jobs(service_id="svc-a")) == 1 + assert len(store.list_backup_jobs(service_id="svc-b")) == 1 + + def test_list_backup_jobs_unfiltered_returns_all(self, store: SettingsStore): + store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + assert len(store.list_backup_jobs()) == 2 + assert len(store.list_backup_jobs(service_id="")) == 2 + + def test_list_backup_runs_filtered_by_service(self, store: SettingsStore): + job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"}) + store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"}) + assert len(store.list_backup_runs(service_id="svc-a")) == 1 + assert len(store.list_backup_runs(service_id="svc-b")) == 1 + + def test_list_backup_alerts_filtered_by_service(self, store: SettingsStore): + job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"}) + store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"}) + store.create_backup_alert( + {"job_id": job_a["id"], "alert_type": "test", "severity": "warning"} + ) + store.create_backup_alert( + {"job_id": job_b["id"], "alert_type": "test", "severity": "warning"} + ) + assert len(store.list_backup_alerts(service_id="svc-a")) == 1 + assert len(store.list_backup_alerts(service_id="svc-b")) == 1 + + def test_list_backup_runs_unfiltered_returns_all(self, store: SettingsStore): + job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"}) + store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"}) + assert len(store.list_backup_runs()) == 2 + + def test_endpoint_threads_service_id_to_store(self, store: SettingsStore): + """GET /api/backups/jobs?service_id=svc-a filters via the endpoint.""" + import media_library_viewer_api.auth as auth_module + from media_library_viewer_api.services import settings_store + + store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"}) + store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"}) + + original_store = settings_store._store + settings_store._store = store + auth_module._API_KEY = None + + try: + client = TestClient(app) + resp = client.get("/api/backups/jobs", params={"service_id": "svc-a"}) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "job-a" + finally: + settings_store._store = original_store + auth_module._API_KEY = None + + def test_post_backup_report(): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test_settings.sqlite" diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts index afec5d8..811ac19 100644 --- a/frontend/src/api/backups.ts +++ b/frontend/src/api/backups.ts @@ -6,8 +6,11 @@ import type { BackupRun, } from "../types/backups"; -export async function fetchBackupJobs(): Promise { - return get("/api/backups/jobs"); +export async function fetchBackupJobs(serviceId?: string): Promise { + return get( + "/api/backups/jobs", + serviceId ? { service_id: serviceId } : undefined, + ); } export async function fetchBackupJob( @@ -21,10 +24,12 @@ export async function fetchBackupJob( export async function fetchBackupRuns( jobId?: string, status?: string, + serviceId?: string, ): Promise { return get("/api/backups/runs", { ...(jobId ? { job_id: jobId } : {}), ...(status ? { status } : {}), + ...(serviceId ? { service_id: serviceId } : {}), }); } @@ -36,6 +41,7 @@ export async function fetchBackupAlerts( jobId?: string, acknowledged?: boolean, severity?: string, + serviceId?: string, ): Promise { return get("/api/backups/alerts", { ...(jobId ? { job_id: jobId } : {}), @@ -43,6 +49,7 @@ export async function fetchBackupAlerts( ? { acknowledged: String(acknowledged) } : {}), ...(severity ? { severity } : {}), + ...(serviceId ? { service_id: serviceId } : {}), }); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 280f707..f9f3e5b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -292,14 +292,23 @@ export const sendUserMessage = (formData: FormData) => postForm("/api/users/message", formData); // Observability summary endpoints -export const fetchAlertmanagerAlerts = () => - get("/api/monitoring/alerts"); +export const fetchAlertmanagerAlerts = (serviceId?: string) => + get( + "/api/monitoring/alerts", + serviceId ? { service_id: serviceId } : undefined, + ); -export const fetchAlertmanagerStatus = () => - get("/api/monitoring/alertmanager-status"); +export const fetchAlertmanagerStatus = (serviceId?: string) => + get( + "/api/monitoring/alertmanager-status", + serviceId ? { service_id: serviceId } : undefined, + ); -export const fetchPrometheusStatus = () => - get("/api/monitoring/prometheus-status"); +export const fetchPrometheusStatus = (serviceId?: string) => + get( + "/api/monitoring/prometheus-status", + serviceId ? { service_id: serviceId } : undefined, + ); export const fetchPrometheusTargets = () => get("/api/monitoring/prometheus-targets"); diff --git a/frontend/src/hooks/__tests__/useBackups.test.ts b/frontend/src/hooks/__tests__/useBackups.test.ts new file mode 100644 index 0000000..015fe40 --- /dev/null +++ b/frontend/src/hooks/__tests__/useBackups.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; +import { useBackupJobs, useBackupRuns, useBackupAlerts } from "../useBackups"; +import { useAlertmanagerAlerts, usePrometheusStatus } from "../useObservability"; + +vi.mock("../../api/client", () => ({ + fetchAlertmanagerAlerts: vi.fn(), + fetchAlertmanagerStatus: vi.fn(), + fetchPrometheusStatus: vi.fn(), + fetchPrometheusTargets: vi.fn(), + fetchMonitoringMachines: vi.fn(), +})); +vi.mock("../../api/backups", () => ({ + fetchBackupJobs: vi.fn(), + fetchBackupRuns: vi.fn(), + fetchBackupAlerts: vi.fn(), + fetchBackupDashboard: vi.fn(), + fetchBackupJob: vi.fn(), + acknowledgeBackupAlert: vi.fn(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("per-instance hook queryKey isolation", () => { + it("useBackupJobs produces different keys for different serviceIds", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook(() => useBackupJobs("svc-a"), { wrapper }); + const { result: b } = renderHook(() => useBackupJobs("svc-b"), { wrapper }); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + // Different serviceId → different query → different cache slot + expect(a).not.toBe(b); + }); + + it("useBackupJobs with undefined serviceId is stable (same key)", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook(() => useBackupJobs(), { wrapper }); + const { result: b } = renderHook(() => useBackupJobs(), { wrapper }); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + }); + + it("useBackupRuns includes serviceId in queryKey", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook( + () => useBackupRuns(undefined, undefined, "svc-a"), + { wrapper }, + ); + expect(a).toBeDefined(); + }); + + it("useBackupAlerts includes serviceId in queryKey", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook( + () => useBackupAlerts(undefined, false, undefined, "svc-a"), + { wrapper }, + ); + expect(a).toBeDefined(); + }); + + it("useAlertmanagerAlerts includes serviceId in queryKey", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook( + () => useAlertmanagerAlerts("svc-a"), + { wrapper }, + ); + expect(a).toBeDefined(); + }); + + it("usePrometheusStatus includes serviceId in queryKey", () => { + const wrapper = createWrapper(); + const { result: a } = renderHook(() => usePrometheusStatus("svc-a"), { + wrapper, + }); + expect(a).toBeDefined(); + }); +}); diff --git a/frontend/src/hooks/useBackups.ts b/frontend/src/hooks/useBackups.ts index 47ec39b..6eb5bec 100644 --- a/frontend/src/hooks/useBackups.ts +++ b/frontend/src/hooks/useBackups.ts @@ -8,10 +8,10 @@ import { fetchBackupRuns, } from "../api/backups"; -export function useBackupJobs() { +export function useBackupJobs(serviceId?: string) { return useQuery({ - queryKey: ["backups", "jobs"], - queryFn: fetchBackupJobs, + queryKey: ["backups", "jobs", serviceId ?? ""], + queryFn: () => fetchBackupJobs(serviceId), refetchInterval: 30_000, }); } @@ -24,18 +24,18 @@ export function useBackupJob(jobId: string) { }); } -export function useBackupRuns(jobId?: string, status?: string) { +export function useBackupRuns(jobId?: string, status?: string, serviceId?: string) { return useQuery({ - queryKey: ["backups", "runs", jobId, status], - queryFn: () => fetchBackupRuns(jobId, status), + queryKey: ["backups", "runs", jobId, status, serviceId ?? ""], + queryFn: () => fetchBackupRuns(jobId, status, serviceId), refetchInterval: 15_000, }); } -export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string) { +export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string, serviceId?: string) { return useQuery({ - queryKey: ["backups", "alerts", jobId, acknowledged, severity], - queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity), + queryKey: ["backups", "alerts", jobId, acknowledged, severity, serviceId ?? ""], + queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId), refetchInterval: 30_000, }); } diff --git a/frontend/src/hooks/useObservability.ts b/frontend/src/hooks/useObservability.ts index 9c213f5..6a43898 100644 --- a/frontend/src/hooks/useObservability.ts +++ b/frontend/src/hooks/useObservability.ts @@ -7,30 +7,30 @@ import { fetchMonitoringMachines, } from "../api/client"; -export function useAlertmanagerAlerts() { +export function useAlertmanagerAlerts(serviceId?: string) { return useQuery({ - queryKey: ["observability", "alerts"], - queryFn: fetchAlertmanagerAlerts, + queryKey: ["observability", "alerts", serviceId ?? ""], + queryFn: () => fetchAlertmanagerAlerts(serviceId), retry: 2, staleTime: 10_000, refetchInterval: 15_000, }); } -export function useAlertmanagerStatus() { +export function useAlertmanagerStatus(serviceId?: string) { return useQuery({ - queryKey: ["observability", "alertmanager-status"], - queryFn: fetchAlertmanagerStatus, + queryKey: ["observability", "alertmanager-status", serviceId ?? ""], + queryFn: () => fetchAlertmanagerStatus(serviceId), retry: 2, staleTime: 10_000, refetchInterval: 30_000, }); } -export function usePrometheusStatus() { +export function usePrometheusStatus(serviceId?: string) { return useQuery({ - queryKey: ["observability", "prometheus-status"], - queryFn: fetchPrometheusStatus, + queryKey: ["observability", "prometheus-status", serviceId ?? ""], + queryFn: () => fetchPrometheusStatus(serviceId), retry: 2, staleTime: 10_000, refetchInterval: 30_000, diff --git a/frontend/src/pages/service-tabs/AlertsTab.tsx b/frontend/src/pages/service-tabs/AlertsTab.tsx index cd40c9e..0e44f25 100644 --- a/frontend/src/pages/service-tabs/AlertsTab.tsx +++ b/frontend/src/pages/service-tabs/AlertsTab.tsx @@ -1,14 +1,9 @@ /** * Alertmanager Alerts tab (spec R2.4, R8.2). * - * Lifts the Alertmanager alerts content from the old cross-service - * ObservabilityPage into an instance-scoped tab. Renders the active-alert - * summary (total + by severity) and the expandable alert list. - * - * The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global / - * first-configured for now — they don't accept a service_id yet. Wiring - * `instance.id` into them is a documented follow-up once the hooks gain the - * parameter. The `instance` prop is accepted for future scoping. + * Instance-scoped tab rendering the active-alert summary (total + by severity) + * and the expandable alert list. Hooks are scoped by instance.id so + * multi-instance setups show data for the selected Alertmanager only. */ import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react"; import { @@ -110,16 +105,12 @@ function AlertItem({ alert }: { alert: AlertmanagerAlert }) { } export function AlertsTab({ instance }: { instance: ServiceInstance }) { - // Global / first-configured hooks for now; instance.id scoping is a - // follow-up (see file docstring). - void instance; - const { data: alertsSummary, isLoading: alertsLoading, error: alertsError, - } = useAlertmanagerAlerts(); - const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(); + } = useAlertmanagerAlerts(instance.id); + const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(instance.id); const statusDetail = status?.up ? status.version diff --git a/frontend/src/pages/service-tabs/JobsTab.tsx b/frontend/src/pages/service-tabs/JobsTab.tsx index e33f525..b33a097 100644 --- a/frontend/src/pages/service-tabs/JobsTab.tsx +++ b/frontend/src/pages/service-tabs/JobsTab.tsx @@ -1,14 +1,8 @@ /** * JobsTab — operational content for the backups service page. * - * Lifted from the old top-level `components/BackupsPage.tsx`. The three - * sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim. - * - * NOTE: the backup hooks currently query globally (no service_id filter). - * The backend gained `service_id` attribution in Slice 3, but the hooks don't - * yet accept a serviceId param. This tab shows ALL backups data for now; - * per-instance scoping by `instance.id` is a follow-up once the hooks gain the - * parameter. + * Instance-scoped: hooks filter by instance.id so multi-instance setups + * show only the selected backups service's jobs, runs, and alerts. */ import { useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -24,15 +18,18 @@ import BackupRunsTable from "../../components/BackupRunsTable"; import type { ServiceInstance } from "../../types"; export function JobsTab({ instance }: { instance: ServiceInstance }) { - // instance.id is not yet used — backup hooks query globally (see file - // docstring). Per-instance scoping is a follow-up. - void instance; const [tab, setTab] = useState("jobs"); - const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(); - const { data: runsData, isLoading: runsLoading } = useBackupRuns(); + const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(instance.id); + const { data: runsData, isLoading: runsLoading } = useBackupRuns( + undefined, + undefined, + instance.id, + ); const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts( undefined, false, + undefined, + instance.id, ); const acknowledgeMutation = useAcknowledgeAlert(); diff --git a/frontend/src/pages/service-tabs/MetricsTab.tsx b/frontend/src/pages/service-tabs/MetricsTab.tsx index 3adec82..20e12c7 100644 --- a/frontend/src/pages/service-tabs/MetricsTab.tsx +++ b/frontend/src/pages/service-tabs/MetricsTab.tsx @@ -1,12 +1,9 @@ /** * Prometheus Metrics tab (spec R2.4, R8.2). * - * Lifts the Prometheus status + targets content from the old cross-service - * ObservabilityPage into an instance-scoped tab. Shows service health and - * the Node Exporter scrape-targets list. - * - * The hooks (usePrometheusStatus, usePrometheusTargets) are global / - * first-configured for now. Wiring `instance.id` is a follow-up. + * Instance-scoped tab showing Prometheus service health. + * usePrometheusStatus is scoped by instance.id; usePrometheusTargets + * stays global (returns Node Exporter scrape targets for external Prom). */ import { Radio } from "lucide-react"; import { @@ -41,15 +38,11 @@ function TargetsTable({ targets }: { targets: PrometheusTarget[] }) { } export function MetricsTab({ instance }: { instance: ServiceInstance }) { - // Global / first-configured hooks for now; instance.id scoping is a - // follow-up (see file docstring). - void instance; - const { data: status, isLoading: statusLoading, error: statusError, - } = usePrometheusStatus(); + } = usePrometheusStatus(instance.id); const { data: targets, isLoading: targetsLoading, diff --git a/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx index ec95414..ee76bde 100644 --- a/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx +++ b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import * as useObservability from "../../../hooks/useObservability"; import { AlertsTab } from "../AlertsTab"; import type { ServiceInstance } from "../../../types"; @@ -15,7 +16,7 @@ const instance: ServiceInstance = { }; vi.mock("../../../hooks/useObservability", () => ({ - useAlertmanagerAlerts: () => ({ + useAlertmanagerAlerts: vi.fn(() => ({ data: { total: 2, by_severity: { critical: 1, warning: 1 }, @@ -46,15 +47,25 @@ vi.mock("../../../hooks/useObservability", () => ({ }, isLoading: false, error: null, - }), - useAlertmanagerStatus: () => ({ + })), + useAlertmanagerStatus: vi.fn(() => ({ data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] }, isLoading: false, error: null, - }), + })), })); describe("AlertsTab", () => { + it("passes instance.id to scoped hooks", () => { + render(); + expect(vi.mocked(useObservability.useAlertmanagerAlerts)).toHaveBeenCalledWith( + "am-1", + ); + expect(vi.mocked(useObservability.useAlertmanagerStatus)).toHaveBeenCalledWith( + "am-1", + ); + }); + it("renders the alert count and alert names", () => { render(); expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();