Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
51 changes: 50 additions & 1 deletion dembrane/frontend/src/components/analysis/hooks/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
// @vitest-environment jsdom
import { i18n } from "@lingui/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "@/components/common/Toaster";
import { bff } from "@/lib/bff";
import { RESULTS_PAGE, useResultsList, useResultsVisit } from "./index";
import {
RESULTS_PAGE,
useRequestAnalysisRun,
useResultsList,
useResultsVisit,
} from "./index";

vi.mock("@/lib/bff", () => ({
bff: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
}));
vi.mock("@/components/common/Toaster", () => ({
toast: { error: vi.fn() },
}));
vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } }));

function wrapper({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -141,3 +151,42 @@ describe("a visit to the list", () => {
);
});
});

describe("requesting a run", () => {
beforeEach(() => i18n.loadAndActivate({ locale: "en-US", messages: {} }));
const refusal = (limit: string) => ({
response: {
data: { detail: { error: "FREE_TIER_LIMIT", limit } },
status: 402,
},
});

it.each(["map", "popcorn"])(
"leaves a used-up free %s to the page's wall",
async (limit) => {
vi.mocked(bff.post).mockRejectedValue(refusal(limit));
const run = renderHook(() => useRequestAnalysisRun("project-1"), {
wrapper,
});
await act(async () => {
await run.result.current
.mutateAsync({ recipe_id: "arguments" })
.catch(() => null);
});
expect(toast.error).not.toHaveBeenCalled();
},
);

it("says so when the run could not start for another reason", async () => {
vi.mocked(bff.post).mockRejectedValue(new Error("Network Error"));
const run = renderHook(() => useRequestAnalysisRun("project-1"), {
wrapper,
});
await act(async () => {
await run.result.current
.mutateAsync({ recipe_id: "arguments" })
.catch(() => null);
});
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
14 changes: 13 additions & 1 deletion dembrane/frontend/src/components/analysis/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import posthog from "posthog-js";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@/components/common/Toaster";
import { API_BASE_URL } from "@/config";
import { invalidateFreeAllowance } from "@/hooks/useFreeAllowance";
import { useServerEvents } from "@/hooks/useServerEvents";
import { bff } from "@/lib/bff";
import { isFreeTierLimitError } from "@/lib/freeTier";

export type AnalysisRecipe = {
id: string;
Expand Down Expand Up @@ -544,7 +546,15 @@ export function useRequestAnalysisRun(projectId: string) {
...body,
idempotency_key: crypto.randomUUID(),
}),
onError: () => toast.error(t`Could not start this run. Try again.`),
onError: (error) => {
// The page opens the wall for a used-up month.
const limit = isFreeTierLimitError(error);
if (limit === "map" || limit === "popcorn") {
void invalidateFreeAllowance(queryClient);
return;
}
toast.error(t`Could not start this run. Try again.`);
},
onSuccess: (_data, body) => {
// Ids and kinds only: no scope key, no parameters, no participant text.
posthog.capture("analysis_run_requested", {
Expand All @@ -555,6 +565,8 @@ export function useRequestAnalysisRun(projectId: string) {
void queryClient.invalidateQueries({
queryKey: analysisKeys.runs(projectId),
});
// A free map or analysis may have been spent.
void invalidateFreeAllowance(queryClient);
},
});
}
Expand Down
191 changes: 188 additions & 3 deletions dembrane/frontend/src/components/chat/AgenticChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ const RUN_ID = "run-1";
// vi.mock factories are hoisted above every top-level binding, so the state
// they close over has to be hoisted with them.
const {
appendAgenticRunMessageMock,
createAgenticRunMock,
runState,
stopAgenticRunMock,
streamOptionsRef,
transcribeMock,
usageState,
} = vi.hoisted(() => ({
appendAgenticRunMessageMock: vi.fn(),
createAgenticRunMock: vi.fn(),
runState: { events: [], status: "running" } as {
events: AgenticRunEvent[];
Expand All @@ -67,15 +69,15 @@ const {
note: "",
transcript: "what I said out loud",
})),
// Mutable so a test can put the workspace over its recording cap.
usageState: { uploadsLocked: false },
// Mutable so a test can put the workspace over its recording cap or on free.
usageState: { free: false, uploadsLocked: false },
}));

vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
appendAgenticRunMessage: vi.fn(),
appendAgenticRunMessage: appendAgenticRunMessageMock,
createAgentInsight: vi.fn(),
createAgenticRun: createAgenticRunMock,
dismissAgentInsight: vi.fn(),
Expand Down Expand Up @@ -180,6 +182,24 @@ vi.mock("@/hooks/useWorkspaceUsage", async (importOriginal) => {
});

// Children that carry their own data layer and nothing to do with run state.
vi.mock("@/hooks/useFreeAllowance", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/useFreeAllowance")>()),
useFreeAllowance: () => ({ data: { free: usageState.free } }),
}));

vi.mock("./FreeTierChatGate", async (importOriginal) => ({
...(await importOriginal<typeof import("./FreeTierChatGate")>()),
ChatUpgradeModal: ({
opened,
reason,
}: {
opened: boolean;
reason: string;
}) =>
opened ? (
<div data-testid="chat-upgrade-modal" data-reason={reason} />
) : null,
}));
vi.mock("./ChatAccordion", () => ({ ChatAccordionItemMenu: () => null }));
vi.mock("./ChatTemplatesMenuConnected", () => ({
ChatTemplatesMenuConnected: () => null,
Expand Down Expand Up @@ -512,6 +532,7 @@ describe("AgenticChatPanel, voice input", () => {
beforeEach(() => {
clock = 0;
usageState.uploadsLocked = false;
usageState.free = false;
vi.spyOn(Date, "now").mockImplementation(() => clock);
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
Object.defineProperty(globalThis.navigator, "mediaDevices", {
Expand Down Expand Up @@ -635,6 +656,83 @@ describe("AgenticChatPanel, voice input", () => {
).toBeTruthy();
});

it("locks the microphone on a free project and offers the upgrade", async () => {
usageState.free = true;
runState.status = "completed";
renderPanel();

const mic = await screen.findByTestId("chat-voice-record-button");
expect(mic.hasAttribute("data-disabled")).toBe(true);
await act(async () => {
fireEvent.click(mic);
});

expect(
globalThis.navigator.mediaDevices.getUserMedia,
).not.toHaveBeenCalled();
expect(transcribeMock).not.toHaveBeenCalled();
expect(
await screen.findByText(/You were trying to use your voice in chat/),
).toBeTruthy();
});

it("opens the upgrade wall when the server refuses a free voice note", async () => {
transcribeMock.mockRejectedValueOnce(
Object.assign(new Error("402"), {
isAxiosError: true,
response: {
data: {
code: "billing.tier_limit",
detail: { error: "FREE_TIER_LIMIT", limit: "audio_month" },
params: { limit: "audio_month" },
},
status: 402,
},
}),
);
runState.status = "completed";
renderPanel();
await startRecording();

clock = 4000;
await act(async () => {
fireEvent.click(screen.getByTestId("chat-voice-stop-button"));
});

expect(
await screen.findByText(/You were trying to use your voice in chat/),
).toBeTruthy();
expect(screen.queryByTestId("chat-voice-error")).toBeNull();
});

it("shows any other 402 as a voice error, not the free wall", async () => {
transcribeMock.mockRejectedValueOnce(
Object.assign(new Error("402"), {
isAxiosError: true,
response: {
data: { code: "conversation.locked", detail: "locked" },
status: 402,
},
}),
);
runState.status = "completed";
renderPanel();
await startRecording();

clock = 4000;
await act(async () => {
fireEvent.click(screen.getByTestId("chat-voice-stop-button"));
});

const alert = await screen.findByTestId("chat-voice-error");
expect(alert.textContent).toContain(
"Something went wrong while transcribing",
);
expect(
screen.queryByText(/You were trying to use your voice in chat/),
).toBeNull();
});

it("says the microphone is blocked rather than failing quietly", async () => {
Object.defineProperty(globalThis.navigator, "mediaDevices", {
configurable: true,
Expand All @@ -655,6 +753,93 @@ describe("AgenticChatPanel, voice input", () => {
});
});

describe("AgenticChatPanel, free turn limit", () => {
const TURN_CARD = "You've reached the free plan limit for this chat.";

const tree = (chatId: string) => (
<MemoryRouter>
<I18nProvider i18n={i18n}>
<MantineProvider>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<AgenticChatPanel chatId={chatId} projectId="project-1" />
</QueryClientProvider>
</MantineProvider>
</I18nProvider>
</MemoryRouter>
);

it("forgets a refused turn when the chat changes", async () => {
runState.events = FINISHED_TURN;
runState.status = "completed";
appendAgenticRunMessageMock.mockRejectedValueOnce({
response: {
data: { detail: { error: "FREE_TIER_LIMIT", limit: "chat_turns" } },
status: 402,
},
});
const { rerender } = render(tree("chat-1"));

await screen.findByText("three themes came up.");
const input = await screen.findByTestId("chat-input-textarea");
fireEvent.change(input, { target: { value: "one more?" } });
await act(async () => {
fireEvent.click(screen.getByTestId("chat-send-button"));
});
await waitFor(() =>
expect(appendAgenticRunMessageMock).toHaveBeenCalledTimes(1),
);
await waitFor(() => expect(document.body.textContent).toContain(TURN_CARD));

rerender(tree("chat-2"));
await waitFor(() =>
expect(document.body.textContent).not.toContain(TURN_CARD),
);
});

it("shows the monthly chat limit, not an error, when the month's chats are used", async () => {
runState.events = FINISHED_TURN;
runState.status = "completed";
appendAgenticRunMessageMock.mockRejectedValueOnce({
response: {
data: {
detail: {
cap: 2,
error: "FREE_TIER_LIMIT",
limit: "chats",
used: 2,
},
},
status: 402,
},
});
render(tree("chat-1"));

await screen.findByText("three themes came up.");
const input = await screen.findByTestId("chat-input-textarea");
fireEvent.change(input, { target: { value: "one more?" } });
await act(async () => {
fireEvent.click(screen.getByTestId("chat-send-button"));
});
await waitFor(() =>
expect(document.body.textContent).toContain(
"You have used this month's free chats.",
),
);
expect(
screen.getByTestId("chat-upgrade-modal").getAttribute("data-reason"),
).toBe("chats");
expect(document.body.textContent).not.toContain(TURN_CARD);
expect(screen.queryByTestId("chat-error-alert")).toBeNull();
expect(document.body.textContent).not.toContain("[object Object]");
});
});

describe("enrichAgenticContent, footnote citations", () => {
const CONVERSATION_ID = "0aa78d5a-1111-2222-3333-444455556666";

Expand Down
Loading
Loading