Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AddCompanyWizardPage from "@/app/(app)/companies/new/page";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const DISCOVERED_PROFILE = {
|
||||
name: "Acme Widgets",
|
||||
official_website: "https://acmewidgets.com",
|
||||
description: "Acme Widgets designs and sells handcrafted mechanical widgets.",
|
||||
monitoring_focus: null,
|
||||
industry: null,
|
||||
country: null,
|
||||
region: null,
|
||||
headquarters: "Austin, Texas",
|
||||
aliases: ["Acme Industries"],
|
||||
competitors: [],
|
||||
public_identifiers: {},
|
||||
potential_sources: [
|
||||
{ source_type: "website", name: "Acme Widgets - Website", base_url: "https://acmewidgets.com" },
|
||||
],
|
||||
sources_consulted: ["https://acmewidgets.com"],
|
||||
};
|
||||
|
||||
const EXISTING_COMPANIES = [
|
||||
{
|
||||
id: "existing-1",
|
||||
name: "Acme Widgets",
|
||||
slug: "acme-widgets",
|
||||
official_website: null,
|
||||
description: null,
|
||||
monitoring_focus: null,
|
||||
industry: null,
|
||||
country: null,
|
||||
region: null,
|
||||
headquarters: null,
|
||||
public_identifiers: {},
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
aliases: [],
|
||||
competitors: [],
|
||||
monitor_configuration: null,
|
||||
report_count: 0,
|
||||
unresolved_alert_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
function mockFetchImplementation(existingCompanies: unknown[] = []) {
|
||||
return vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
const path = url.replace("http://localhost:8000", "");
|
||||
if (path === "/api/v1/companies/discover" && init?.method === "POST") {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => DISCOVERED_PROFILE });
|
||||
}
|
||||
if (path === "/api/v1/companies" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => existingCompanies });
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetchImplementation());
|
||||
});
|
||||
|
||||
describe("AddCompanyWizardPage", () => {
|
||||
it("only requires a company name on the Discover step", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<AddCompanyWizardPage />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /discover company/i }));
|
||||
|
||||
expect(await screen.findByText(/company name is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discovers a profile and pre-fills the Review step, editable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<AddCompanyWizardPage />);
|
||||
|
||||
await user.type(screen.getByLabelText("Company name"), "Acme Widgets");
|
||||
await user.click(screen.getByRole("button", { name: /discover company/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/here's what we found for/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const website = screen.getByLabelText("Official website") as HTMLInputElement;
|
||||
expect(website.value).toBe("https://acmewidgets.com");
|
||||
const headquarters = screen.getByLabelText("Headquarters") as HTMLInputElement;
|
||||
expect(headquarters.value).toBe("Austin, Texas");
|
||||
const description = screen.getByLabelText("Description") as HTMLTextAreaElement;
|
||||
expect(description.value).toBe(
|
||||
"Acme Widgets designs and sells handcrafted mechanical widgets.",
|
||||
);
|
||||
const aliases = screen.getByLabelText(/known aliases/i) as HTMLInputElement;
|
||||
expect(aliases.value).toBe("Acme Industries");
|
||||
|
||||
expect(screen.getByText(/we'll start monitoring 1 source/i)).toBeInTheDocument();
|
||||
|
||||
// Still editable - the whole point of the review step.
|
||||
await user.clear(headquarters);
|
||||
await user.type(headquarters, "Denver, Colorado");
|
||||
expect(headquarters.value).toBe("Denver, Colorado");
|
||||
});
|
||||
|
||||
it("warns about a likely duplicate before discovering, and proceeds after Continue anyway", async () => {
|
||||
vi.stubGlobal("fetch", mockFetchImplementation(EXISTING_COMPANIES));
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<AddCompanyWizardPage />);
|
||||
|
||||
await user.type(screen.getByLabelText("Company name"), "Acme Widgets");
|
||||
await user.click(screen.getByRole("button", { name: /discover company/i }));
|
||||
|
||||
expect(await screen.findByText(/you might already be monitoring/i)).toBeInTheDocument();
|
||||
// Discovery must not have fired yet - still on the Discover step.
|
||||
expect(screen.queryByText(/here's what we found for/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /continue anyway/i }));
|
||||
await user.click(screen.getByRole("button", { name: /discover company/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/here's what we found for/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/you might already be monitoring/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AlertsPage from "@/app/(app)/alerts/page";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
const COMPANIES = [
|
||||
{ id: "company-1", name: "Acme Mobility Systems" },
|
||||
{ id: "company-2", name: "Globex Corp" },
|
||||
];
|
||||
|
||||
const ALERTS = [
|
||||
{
|
||||
id: "alert-1",
|
||||
company_id: "company-1",
|
||||
detected_change_id: "change-1",
|
||||
title: "Base plan price increased",
|
||||
summary: "Pricing changed.",
|
||||
why_it_matters: "Signals margin pressure.",
|
||||
severity: "high",
|
||||
confidence: 0.82,
|
||||
read: false,
|
||||
resolved: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function mockFetchImplementation() {
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
const path = url.replace("http://localhost:8000", "");
|
||||
if (path.startsWith("/api/v1/companies")) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => COMPANIES });
|
||||
}
|
||||
if (path.startsWith("/api/v1/alerts")) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => ALERTS });
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetchImplementation());
|
||||
});
|
||||
|
||||
describe("AlertsPage", () => {
|
||||
it("renders alerts with the resolved company name", async () => {
|
||||
renderWithQueryClient(<AlertsPage />);
|
||||
|
||||
expect(await screen.findByText("Base plan price increased")).toBeInTheDocument();
|
||||
const list = screen.getByRole("list");
|
||||
expect(within(list).getByText(/Acme Mobility Systems/)).toBeInTheDocument();
|
||||
expect(within(list).getByText("high")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-fetches with a severity filter when changed", async () => {
|
||||
const fetchMock = mockFetchImplementation();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<AlertsPage />);
|
||||
|
||||
await screen.findByText("Base plan price increased");
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Filter by severity" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Critical" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calledUrls = fetchMock.mock.calls.map((call) => call[0] as string);
|
||||
expect(calledUrls.some((u) => u.includes("severity=critical"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DashboardAnalyticsSection } from "@/components/dashboard/analytics-section";
|
||||
import type { DashboardAnalytics } from "@/lib/types";
|
||||
|
||||
const EMPTY_ANALYTICS: DashboardAnalytics = {
|
||||
changes_by_type: {
|
||||
new_document: 0,
|
||||
removed_document: 0,
|
||||
content_modified: 0,
|
||||
price_change: 0,
|
||||
leadership_change: 0,
|
||||
filing_new: 0,
|
||||
},
|
||||
alerts_by_severity: { critical: 0, high: 0, medium: 0, low: 0 },
|
||||
sources_by_status: {
|
||||
active: 0,
|
||||
disabled: 0,
|
||||
rate_limited: 0,
|
||||
auth_required: 0,
|
||||
blocked_by_policy: 0,
|
||||
failed: 0,
|
||||
},
|
||||
runs_by_day: [],
|
||||
recent_signals: [],
|
||||
};
|
||||
|
||||
describe("DashboardAnalyticsSection", () => {
|
||||
it("shows empty states when there is no activity yet", () => {
|
||||
render(<DashboardAnalyticsSection analytics={EMPTY_ANALYTICS} />);
|
||||
|
||||
expect(screen.getByText(/no changes detected in the last 30 days/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no alerts in the last 30 days/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no monitoring runs in the last 30 days/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no signals detected yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a recent signal with company link and severity", () => {
|
||||
const analytics: DashboardAnalytics = {
|
||||
...EMPTY_ANALYTICS,
|
||||
recent_signals: [
|
||||
{
|
||||
id: "change-1",
|
||||
company_id: "company-1",
|
||||
company_name: "Acme Mobility",
|
||||
change_type: "leadership_change",
|
||||
severity: "high",
|
||||
confidence_score: 0.8,
|
||||
summary: "New CEO announced",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<DashboardAnalyticsSection analytics={analytics} />);
|
||||
|
||||
expect(screen.getByText("New CEO announced")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Acme Mobility" })).toHaveAttribute(
|
||||
"href",
|
||||
"/companies/company-1",
|
||||
);
|
||||
expect(screen.getByText("high")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// Fresh module import per test so the internal `refreshPromise` module-level
|
||||
// state (shared across concurrent 401s) never leaks between tests.
|
||||
async function loadApiClient() {
|
||||
return import("@/lib/api-client");
|
||||
}
|
||||
|
||||
describe("api client automatic token refresh", () => {
|
||||
it("transparently retries once after a 401 when the refresh succeeds", async () => {
|
||||
const { api, setTokens, getAccessToken } = await loadApiClient();
|
||||
setTokens("expired-access", "valid-refresh");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
// The actual request, with the now-expired access token -> 401.
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Invalid or expired token" }),
|
||||
})
|
||||
// POST /auth/refresh -> a fresh token pair.
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
token_type: "bearer",
|
||||
expires_in_minutes: 15,
|
||||
}),
|
||||
})
|
||||
// The retried original request, now with the new access token.
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: "1", email: "[email protected]" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await api.me();
|
||||
|
||||
expect(result).toEqual({ id: "1", email: "[email protected]" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(getAccessToken()).toBe("new-access");
|
||||
|
||||
const retryCall = fetchMock.mock.calls[2];
|
||||
const retryHeaders = retryCall?.[1]?.headers as Headers;
|
||||
expect(retryHeaders.get("Authorization")).toBe("Bearer new-access");
|
||||
});
|
||||
|
||||
it("clears tokens and reports the original 401 when the refresh itself also fails", async () => {
|
||||
const { api, setTokens, getAccessToken, getRefreshToken, ApiError } = await loadApiClient();
|
||||
setTokens("expired-access", "expired-refresh");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Invalid or expired token" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Invalid or expired refresh token" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(api.me()).rejects.toBeInstanceOf(ApiError);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // original request + failed refresh, no retry
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(getRefreshToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not attempt a refresh when there is no refresh token at all", async () => {
|
||||
const { api, clearTokens } = await loadApiClient();
|
||||
clearTokens();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Not authenticated" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(api.me()).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // no refresh attempt made
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "@/app/login/page";
|
||||
import RegisterPage from "@/app/register/page";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Not authenticated" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("LoginPage", () => {
|
||||
it("shows validation errors for an empty submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<LoginPage />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByText(/enter a valid email address/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/enter your password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the register page", async () => {
|
||||
renderWithQueryClient(<LoginPage />);
|
||||
expect(await screen.findByRole("link", { name: /create one/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/register",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RegisterPage", () => {
|
||||
it("rejects a weak password before submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<RegisterPage />);
|
||||
|
||||
await user.type(screen.getByLabelText(/full name/i), "Ada Lovelace");
|
||||
await user.type(screen.getByLabelText(/^email$/i), "[email protected]");
|
||||
await user.type(screen.getByLabelText(/^password$/i), "allletters");
|
||||
await user.click(screen.getByRole("button", { name: /create account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/must contain at least one letter and one digit/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("links to the login page", async () => {
|
||||
renderWithQueryClient(<RegisterPage />);
|
||||
expect(await screen.findByRole("link", { name: /sign in/i })).toHaveAttribute("href", "/login");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CompanyStatusBadge, SeverityBadge } from "@/components/ui/badge";
|
||||
import { SEVERITY_LEVELS } from "@/lib/types";
|
||||
|
||||
describe("SeverityBadge", () => {
|
||||
it.each(SEVERITY_LEVELS)("renders the %s severity label", (severity) => {
|
||||
render(<SeverityBadge severity={severity} />);
|
||||
expect(screen.getByText(severity)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyStatusBadge", () => {
|
||||
it("renders active status", () => {
|
||||
render(<CompanyStatusBadge status="active" />);
|
||||
expect(screen.getByText("active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders paused status", () => {
|
||||
render(<CompanyStatusBadge status="paused" />);
|
||||
expect(screen.getByText("paused")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LandingPage from "@/app/page";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
function mockFetchImplementation(isLocalhost: boolean) {
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
const path = url.replace("http://localhost:8000", "");
|
||||
if (path.startsWith("/api/v1/system/status")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
app_env: "development",
|
||||
auth_mode: "local",
|
||||
llm_provider: "mock",
|
||||
search_provider: "mock",
|
||||
sms_enabled: false,
|
||||
sms_provider: "twilio",
|
||||
ninjapear_configured: false,
|
||||
ninjapear_credit_balance: null,
|
||||
ninjapear_estimated_credits_per_company: null,
|
||||
is_localhost: isLocalhost,
|
||||
components: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
|
||||
});
|
||||
}
|
||||
|
||||
describe("LandingPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetchImplementation(true));
|
||||
});
|
||||
|
||||
it("renders the value proposition and primary calls to action", async () => {
|
||||
renderWithQueryClient(<LandingPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /know what your competitors are doing/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("link", { name: /open dashboard \(local mode\)/i }),
|
||||
).toHaveAttribute("href", "/dashboard");
|
||||
expect(screen.getByRole("link", { name: /sign in/i })).toHaveAttribute("href", "/login");
|
||||
expect(screen.getByRole("link", { name: /create account/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/register",
|
||||
);
|
||||
});
|
||||
|
||||
it("discloses the public-information-only collection policy", () => {
|
||||
renderWithQueryClient(<LandingPage />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /only publicly available information/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the local-mode wording when the request isn't from localhost", async () => {
|
||||
vi.stubGlobal("fetch", mockFetchImplementation(false));
|
||||
renderWithQueryClient(<LandingPage />);
|
||||
|
||||
expect(await screen.findByRole("link", { name: /^open dashboard$/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard",
|
||||
);
|
||||
expect(screen.queryByText(/local mode/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ForgotPasswordPage from "@/app/forgot-password/page";
|
||||
import ResetPasswordPage from "@/app/reset-password/page";
|
||||
import UnbanRequestPage from "@/app/unban-request/page";
|
||||
import VerifyEmailPage from "@/app/verify-email/page";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
let searchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
searchParams = new URLSearchParams();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ detail: "Not authenticated" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("VerifyEmailPage", () => {
|
||||
it("shows a validation error for an empty code", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<VerifyEmailPage />);
|
||||
await user.click(await screen.findByRole("button", { name: /verify email/i }));
|
||||
expect(await screen.findByText(/^enter the 6-digit code$/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the email address pulled from the query string", async () => {
|
||||
searchParams = new URLSearchParams({ email: "[email protected]" });
|
||||
renderWithQueryClient(<VerifyEmailPage />);
|
||||
expect(await screen.findByText(/ada@example\.com/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
it("rejects an invalid email", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<ForgotPasswordPage />);
|
||||
await user.type(screen.getByLabelText(/email/i), "not-an-email");
|
||||
await user.click(screen.getByRole("button", { name: /send reset code/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/enter a valid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("links back to sign in", async () => {
|
||||
renderWithQueryClient(<ForgotPasswordPage />);
|
||||
expect(await screen.findByRole("link", { name: /back to sign in/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/login",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResetPasswordPage", () => {
|
||||
it("prefills email from the query string", async () => {
|
||||
searchParams = new URLSearchParams({ email: "[email protected]" });
|
||||
renderWithQueryClient(<ResetPasswordPage />);
|
||||
expect(await screen.findByDisplayValue("[email protected]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects a weak new password", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<ResetPasswordPage />);
|
||||
await user.type(screen.getByLabelText(/^email$/i), "[email protected]");
|
||||
await user.type(screen.getByLabelText(/reset code/i), "123456");
|
||||
await user.type(screen.getByLabelText(/new password/i), "allletters");
|
||||
await user.click(screen.getByRole("button", { name: /reset password/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/must contain at least one letter and one digit/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnbanRequestPage", () => {
|
||||
it("submits and shows a confirmation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: async () => ({}),
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<UnbanRequestPage />);
|
||||
|
||||
await user.type(screen.getByLabelText(/message/i), "This looks like a mistake.");
|
||||
await user.click(screen.getByRole("button", { name: /send request/i }));
|
||||
|
||||
expect(await screen.findByText(/request received/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links back to sign in", async () => {
|
||||
renderWithQueryClient(<UnbanRequestPage />);
|
||||
expect(await screen.findByRole("link", { name: /back to sign in/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/login",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// jsdom doesn't implement these - Radix UI's Select (and other primitives
|
||||
// built on pointer events) call them unconditionally, so tests that open
|
||||
// one throw without these no-op polyfills.
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
}
|
||||
if (!Element.prototype.setPointerCapture) {
|
||||
Element.prototype.setPointerCapture = () => {};
|
||||
}
|
||||
if (!Element.prototype.releasePointerCapture) {
|
||||
Element.prototype.releasePointerCapture = () => {};
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SystemSecretRow } from "@/components/ui/system-secret-row";
|
||||
import type { SystemSecretStatus } from "@/lib/types";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
const unconfiguredSecret: SystemSecretStatus = {
|
||||
key: "turnstile_secret",
|
||||
label: "Turnstile Secret Key",
|
||||
configured: false,
|
||||
value: null,
|
||||
};
|
||||
|
||||
const configuredSecret: SystemSecretStatus = {
|
||||
key: "turnstile_site_key",
|
||||
label: "Turnstile Site Key",
|
||||
configured: true,
|
||||
value: "0x-my-site-key",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
describe("SystemSecretRow", () => {
|
||||
it("shows the label and a masked input for a configured secret", () => {
|
||||
renderWithQueryClient(<SystemSecretRow secret={configuredSecret} />);
|
||||
expect(screen.getByText("Turnstile Site Key")).toBeInTheDocument();
|
||||
const textbox = screen.getByDisplayValue("0x-my-site-key") as HTMLInputElement;
|
||||
expect(textbox.type).toBe("password");
|
||||
});
|
||||
|
||||
it("toggles the input between masked and revealed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<SystemSecretRow secret={configuredSecret} />);
|
||||
const textbox = screen.getByDisplayValue("0x-my-site-key") as HTMLInputElement;
|
||||
expect(textbox.type).toBe("password");
|
||||
|
||||
await user.click(screen.getByTitle(/show key/i));
|
||||
expect(textbox.type).toBe("text");
|
||||
|
||||
await user.click(screen.getByTitle(/hide key/i));
|
||||
expect(textbox.type).toBe("password");
|
||||
});
|
||||
|
||||
it("disables Update until the value actually changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<SystemSecretRow secret={unconfiguredSecret} />);
|
||||
const updateButton = screen.getByRole("button", { name: /update/i });
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
const textbox = screen.getByPlaceholderText(/not set/i);
|
||||
await user.type(textbox, "sk-new-secret");
|
||||
expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits the new value via PUT to the keyed endpoint and reflects the saved value", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ...unconfiguredSecret, configured: true, value: "sk-new-secret" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<SystemSecretRow secret={unconfiguredSecret} />);
|
||||
|
||||
const textbox = screen.getByPlaceholderText(/not set/i);
|
||||
await user.type(textbox, "sk-new-secret");
|
||||
await user.click(screen.getByRole("button", { name: /update/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/system/secrets/turnstile_secret"),
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ value: "sk-new-secret" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("sk-new-secret")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function renderWithQueryClient(ui: ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnstileWidget, type TurnstileWidgetHandle } from "@/components/ui/turnstile-widget";
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { turnstile?: unknown }).turnstile;
|
||||
});
|
||||
|
||||
describe("TurnstileWidget", () => {
|
||||
it("renders nothing when no site key is configured", () => {
|
||||
const { container } = render(<TurnstileWidget siteKey={null} onToken={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders via window.turnstile once the script loads, reports tokens, and resets", () => {
|
||||
let capturedCallback: ((token: string) => void) | undefined;
|
||||
const renderMock = vi.fn(
|
||||
(_container: HTMLElement, options: { callback: (t: string) => void }) => {
|
||||
capturedCallback = options.callback;
|
||||
return "widget-1";
|
||||
},
|
||||
);
|
||||
const resetMock = vi.fn();
|
||||
const removeMock = vi.fn();
|
||||
window.turnstile = { render: renderMock, reset: resetMock, remove: removeMock };
|
||||
|
||||
const onToken = vi.fn();
|
||||
const ref = createRef<TurnstileWidgetHandle>();
|
||||
|
||||
render(<TurnstileWidget ref={ref} siteKey="test-site-key" onToken={onToken} />);
|
||||
|
||||
// jsdom never actually executes the remote Cloudflare script - simulate
|
||||
// next/script's load detection by firing "load" on the tag it inserted.
|
||||
const scriptEl = document.querySelector('script[src*="turnstile"]');
|
||||
expect(scriptEl).not.toBeNull();
|
||||
act(() => {
|
||||
scriptEl?.dispatchEvent(new Event("load"));
|
||||
});
|
||||
|
||||
expect(renderMock).toHaveBeenCalledTimes(1);
|
||||
expect(renderMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
sitekey: "test-site-key",
|
||||
action: "turnstile-spin-v2",
|
||||
});
|
||||
|
||||
capturedCallback?.("fake-token");
|
||||
expect(onToken).toHaveBeenCalledWith("fake-token");
|
||||
|
||||
act(() => {
|
||||
ref.current?.reset();
|
||||
});
|
||||
expect(resetMock).toHaveBeenCalledWith("widget-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserApiKeyRow } from "@/components/ui/user-api-key-row";
|
||||
import type { UserApiKeyStatus } from "@/lib/types";
|
||||
import { renderWithQueryClient } from "./test-utils";
|
||||
|
||||
const anthropicKey: UserApiKeyStatus = {
|
||||
provider: "anthropic",
|
||||
label: "Anthropic",
|
||||
configured: false,
|
||||
value: null,
|
||||
credits: null,
|
||||
credits_note: "Anthropic doesn't expose a credit/usage-balance API.",
|
||||
free: false,
|
||||
requires_government_id: false,
|
||||
};
|
||||
|
||||
const usptoKey: UserApiKeyStatus = {
|
||||
provider: "uspto",
|
||||
label: "USPTO",
|
||||
configured: true,
|
||||
value: "my-uspto-key",
|
||||
credits: null,
|
||||
credits_note: "Free - USPTO Open Data Portal has no usage limit or credit cost.",
|
||||
free: true,
|
||||
requires_government_id: true,
|
||||
};
|
||||
|
||||
const ninjapearKey: UserApiKeyStatus = {
|
||||
provider: "ninjapear",
|
||||
label: "NinjaPear",
|
||||
configured: true,
|
||||
value: "my-ninjapear-key",
|
||||
credits: null,
|
||||
credits_note: "Credit balance shown in System configuration below.",
|
||||
free: false,
|
||||
requires_government_id: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
describe("UserApiKeyRow", () => {
|
||||
it("shows the provider label and a masked input for a configured key", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
|
||||
expect(screen.getByText("USPTO")).toBeInTheDocument();
|
||||
const textbox = screen.getByDisplayValue("my-uspto-key") as HTMLInputElement;
|
||||
expect(textbox.type).toBe("password");
|
||||
});
|
||||
|
||||
it("shows the free + government-ID badge for USPTO", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
|
||||
expect(screen.getByText(/free · requires government id approval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the free badge for a paid provider", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
|
||||
expect(screen.queryByText(/requires government id approval/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the input between masked and revealed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={usptoKey} />);
|
||||
const textbox = screen.getByDisplayValue("my-uspto-key") as HTMLInputElement;
|
||||
expect(textbox.type).toBe("password");
|
||||
|
||||
await user.click(screen.getByTitle(/show key/i));
|
||||
expect(textbox.type).toBe("text");
|
||||
|
||||
await user.click(screen.getByTitle(/hide key/i));
|
||||
expect(textbox.type).toBe("password");
|
||||
});
|
||||
|
||||
it("disables Update until the value actually changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
|
||||
const updateButton = screen.getByRole("button", { name: /update/i });
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
const textbox = screen.getByPlaceholderText(/^not set$/i);
|
||||
await user.type(textbox, "sk-new-key");
|
||||
expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits the new key via PUT and reflects the saved value", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ...anthropicKey, configured: true, value: "sk-new-key" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={anthropicKey} />);
|
||||
|
||||
const textbox = screen.getByPlaceholderText(/^not set$/i);
|
||||
await user.type(textbox, "sk-new-key");
|
||||
await user.click(screen.getByRole("button", { name: /update/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/user-api-keys/anthropic"),
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ key: "sk-new-key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("sk-new-key")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the backend's own credits field and shows nothing extra when liveCredits is not passed", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} />);
|
||||
expect(
|
||||
screen.getByText(/credit balance shown in system configuration below/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/credits remaining/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the liveCredits number when passed, sourced from system status rather than this row's own fetch", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} liveCredits={891} />);
|
||||
expect(screen.getByText(/891 credits remaining/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the note-only text when liveCredits is explicitly null", () => {
|
||||
renderWithQueryClient(<UserApiKeyRow apiKey={ninjapearKey} liveCredits={null} />);
|
||||
expect(screen.queryByText(/credits remaining/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/credit balance shown in system configuration below/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user