1
2
11(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12import { act, type ReactNode } from "react";
13import { createRoot, type Root } from "react-dom/client";
14import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
15import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
17const mocks = vi.hoisted(() => ({
18 toast: vi.fn(),
19 jobsOverview: vi.fn(async () => ({
20 scope: "cluster" as const,
21 queues: [
22 { name: "email", waiting: 0, active: 0, completed: 0, failed: 1 },
23 { name: "files", waiting: 2, active: 1, completed: 3, failed: 0 }
24 ]
25 })),
26 jobsFailed: vi.fn(async () => ({
27 scope: "cluster" as const,
28 jobs: [
29 {
30 queue: "email",
31 jobId: "j-1",
32 name: "email-notification",
33 payload: "{\"a\":1}",
34 failedReason: "boom",
35 failedAt: "2026-01-01T00:00:00.000Z",
36 attemptsMade: 1
37 }
38 ]
39 })),
40 jobsRequeue: vi.fn(async () => ({ queued: true as const, queue: "email" as const, jobId: "j-1" })),
41
42
43 searchHealth: vi.fn(async (): Promise<any> => ({
44 healthy: true,
45 error: null,
46 workspaces: 1,
47 sourceCount: 2,
48 docCount: 2,
49 drift: 0,
50 lagMs: 500,
51 lastIndexedAt: "2026-01-01T00:00:00.000Z",
52 lastReconcileAt: null
53 })),
54 searchReindex: vi.fn(async () => ({ jobId: "ri-1", status: "processing" as const, workspaceId: null })),
55 notificationsHealth: vi.fn(async () => ({
56 workspaces: 1,
57 inAppTotal: 5,
58 inAppUnread: 2,
59 emailQueued: 3,
60 emailFailed: 1,
61 failedJobs: [
62 {
63 notificationId: "notif-1",
64 jobId: "j-1",
65 name: "email-notification",
66 failedReason: "boom",
67 failedAt: "2026-01-01T00:00:00.000Z",
68 attemptsMade: 1
69 }
70 ]
71 }))
72}));
73
74vi.mock("@/lib/org-admin", () => ({
75 orgAdminApi: {
76 jobsOverview: mocks.jobsOverview,
77 jobsFailed: mocks.jobsFailed,
78 jobsRequeue: mocks.jobsRequeue,
79 searchHealth: mocks.searchHealth,
80 searchReindex: mocks.searchReindex,
81 notificationsHealth: mocks.notificationsHealth
82 }
83}));
84
85vi.mock("@/stores/ui-store", () => ({ toast: mocks.toast }));
86
87import { AdminJobsTab } from "../JobsTab";
88import { AdminSearchHealthTab } from "../SearchHealthTab";
89import { AdminNotificationsTab } from "../NotificationsTab";
90
91let root: Root;
92let host: HTMLElement;
93let client: QueryClient;
94
95function q(sel: string): Element | null {
96 return host.querySelector(sel);
97}
98function qs(sel: string): string | null {
99 return host.querySelector(sel)?.textContent ?? null;
100}
101
102async function render(node: ReactNode) {
103 client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
104 root.render(
105 <QueryClientProvider client={client}>
106 {node}
107 </QueryClientProvider>
108 );
109}
110
111
112async function waitUntil(predicate: () => boolean, budgetMs = 2000) {
113 const started = Date.now();
114 while (!predicate()) {
115 if (Date.now() - started > budgetMs) return;
116 await act(async () => {
117 await new Promise((r) => setTimeout(r, 10));
118 });
119 }
120}
121
122beforeEach(() => {
123 document.body.innerHTML = "";
124 host = document.createElement("div");
125 document.body.appendChild(host);
126 root = createRoot(host);
127 vi.clearAllMocks();
128});
129
130afterEach(async () => {
131 await act(async () => root.unmount());
132 host.remove();
133});
134
135describe("AdminJobsTab (T-176)", () => {
136 it("renders the cluster-scope queue counts + the failed job with a Requeue action", async () => {
137 await render(<AdminJobsTab orgId="org1" />);
138 await waitUntil(() => Boolean(q('[data-testid="jobs-queue-email"]')));
139
140 expect(host.textContent).toContain("cluster-wide");
141 expect(q('[data-testid="jobs-queue-email"]')).toBeTruthy();
142 expect(q('[data-testid="jobs-queue-files"]')).toBeTruthy();
143 expect(qs('[data-testid="jobs-queue-files"]')).toContain("2");
144
145
146 expect(q('[data-testid="jobs-requeue-j-1"]')).toBeTruthy();
147 });
148
149 it("Requeue calls orgAdminApi.jobsRequeue with the job's queue + jobId", async () => {
150 await render(<AdminJobsTab orgId="org1" />);
151 await waitUntil(() => Boolean(q('[data-testid="jobs-requeue-j-1"]')));
152 await act(async () => {
153 (q('[data-testid="jobs-requeue-j-1"]') as HTMLButtonElement).click();
154 await new Promise((r) => setTimeout(r, 30));
155 });
156 expect(mocks.jobsRequeue).toHaveBeenCalledWith("org1", { queue: "email", jobId: "j-1" });
157 expect(mocks.toast).toHaveBeenCalledWith("success", "Job requeued");
158 });
159});
160
161describe("AdminSearchHealthTab (T-177)", () => {
162 it("renders the healthy state + source/doc counts and a reindex trigger", async () => {
163 await render(<AdminSearchHealthTab orgId="org1" />);
164 await waitUntil(() => Boolean(q('[data-testid="search-health-ok"]')));
165 expect(q('[data-testid="search-health-ok"]')).toBeTruthy();
166 expect(q('[data-testid="search-health-down"]')).toBeNull();
167 expect(host.textContent).toContain("Source docs");
168 expect(host.textContent).toContain("Index docs");
169 expect(q('[data-testid="search-health-reindex"]')).toBeTruthy();
170 });
171
172 it("Reindex enqueues an org-scoped reindex", async () => {
173 await render(<AdminSearchHealthTab orgId="org1" />);
174 await waitUntil(() => Boolean(q('[data-testid="search-health-reindex"]')));
175 await act(async () => {
176 (q('[data-testid="search-health-reindex"]') as HTMLButtonElement).click();
177 await new Promise((r) => setTimeout(r, 30));
178 });
179 expect(mocks.searchReindex).toHaveBeenCalledWith("org1");
180 });
181
182 it("search down → a 'unavailable' state (never a spinner loop)", async () => {
183 mocks.searchHealth.mockResolvedValueOnce({
184 healthy: false,
185 error: "SEARCH_UNAVAILABLE",
186 workspaces: 1,
187 sourceCount: 2,
188 docCount: null,
189 drift: null,
190 lagMs: null,
191 lastIndexedAt: null,
192 lastReconcileAt: null
193 });
194 await render(<AdminSearchHealthTab orgId="org1" />);
195 await waitUntil(() => Boolean(q('[data-testid="search-health-down"]')));
196 expect(q('[data-testid="search-health-down"]')).toBeTruthy();
197 expect(host.textContent).toContain("SEARCH_UNAVAILABLE");
198 });
199});
200
201describe("AdminNotificationsTab (T-178)", () => {
202 it("renders in-app volume + email queued/failed + the org-scoped failed jobs", async () => {
203 await render(<AdminNotificationsTab orgId="org1" />);
204 await waitUntil(() => (host.textContent ?? "").includes("In-app total"));
205 expect(host.textContent).toContain("In-app total");
206 expect(host.textContent).toContain("Email queued (cluster)");
207 expect(host.textContent).toContain("Email failed (org)");
208
209 expect(host.textContent).toContain("notif-1");
210 expect(host.textContent).toContain("boom");
211 });
212});
213