mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
Flow drops from five steps to three: role + use_case merge into a single About-you screen (one Skip covers both; Continue stamps skip markers on whichever group was left unanswered), and the source question leaves onboarding entirely. Source is now collected only by the workspace source-backfill prompt, which additionally waits until agents/squads have completed at least SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace — attribution is asked after Multica has visibly delivered value, not before. The count rides a limit:1 issues query keyed under issueKeys.all so realtime invalidations keep it fresh, enabled only for users who still owe an answer. Server: questionnaire complete() narrows to role + use_case so the funnel step doesn't stall on the now-deferred source; a new metrics-only onboarding_source_submitted event (+ Prometheus counter) tracks the backfill prompt's answer/decline transition once per user. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { I18nProvider } from "@multica/core/i18n/react";
|
|
import enCommon from "../locales/en/common.json";
|
|
import enOnboarding from "../locales/en/onboarding.json";
|
|
|
|
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
|
|
|
const { mockUser, mockSaveQuestionnaire, mockWorkspace, mockAgentDoneTotal, mockListIssues } =
|
|
vi.hoisted(() => ({
|
|
mockUser: { value: null as null | Record<string, unknown> },
|
|
mockSaveQuestionnaire: vi.fn(),
|
|
mockWorkspace: {
|
|
value: { id: "ws-1", slug: "ws-1" } as null | { id: string; slug: string },
|
|
},
|
|
mockAgentDoneTotal: { value: 3 },
|
|
mockListIssues: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
const useAuthStore = Object.assign(
|
|
(selector: (s: { user: unknown }) => unknown) =>
|
|
selector({ user: mockUser.value }),
|
|
{ getState: () => ({ user: mockUser.value }) },
|
|
);
|
|
return { ...actual, useAuthStore };
|
|
});
|
|
|
|
vi.mock("@multica/core/onboarding", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/onboarding")>(
|
|
"@multica/core/onboarding",
|
|
);
|
|
return { ...actual, saveQuestionnaire: mockSaveQuestionnaire };
|
|
});
|
|
|
|
vi.mock("@multica/core/paths", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/paths")>(
|
|
"@multica/core/paths",
|
|
);
|
|
return { ...actual, useCurrentWorkspace: () => mockWorkspace.value };
|
|
});
|
|
|
|
vi.mock("@multica/core/api", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/api")>(
|
|
"@multica/core/api",
|
|
);
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
listIssues: mockListIssues,
|
|
},
|
|
};
|
|
});
|
|
|
|
import { SourceBackfillModal } from "./source-backfill-modal";
|
|
|
|
function setUser(partial: Record<string, unknown> | null) {
|
|
mockUser.value = partial;
|
|
}
|
|
|
|
function wipeDismissCounters() {
|
|
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
|
const k = window.localStorage.key(i);
|
|
if (k && k.startsWith("multica.source_backfill.dismiss.")) {
|
|
window.localStorage.removeItem(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default tests run with reduced-motion *on* so the modal's entrance
|
|
* delay short-circuits and the dialog opens synchronously — keeps the
|
|
* behavioural tests focused on selection / submit / skip semantics
|
|
* rather than fighting timers. The dedicated entrance-delay test below
|
|
* overrides this to assert the deferred-open path.
|
|
*/
|
|
function mockPrefersReducedMotion(matches: boolean) {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: (q: string) => ({
|
|
matches: q.includes("reduce") ? matches : false,
|
|
media: q,
|
|
onchange: null,
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSaveQuestionnaire.mockReset();
|
|
mockSaveQuestionnaire.mockResolvedValue(undefined);
|
|
mockListIssues.mockReset();
|
|
// Default: agents have already completed enough issues that the
|
|
// workspace-level gate passes — tests about the user-level gate and
|
|
// submit/skip semantics shouldn't have to care about it.
|
|
mockAgentDoneTotal.value = 3;
|
|
mockListIssues.mockImplementation(async () => ({
|
|
issues: [],
|
|
total: mockAgentDoneTotal.value,
|
|
}));
|
|
mockWorkspace.value = { id: "ws-1", slug: "ws-1" };
|
|
setUser(null);
|
|
wipeDismissCounters();
|
|
mockPrefersReducedMotion(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
wipeDismissCounters();
|
|
});
|
|
|
|
function renderModal() {
|
|
const qc = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
<SourceBackfillModal />
|
|
</I18nProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("SourceBackfillModal", () => {
|
|
it("does not render when there is no user", () => {
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render when the user already recorded a source", () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: ["search"] },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
// A settled user must not even pay for the count query.
|
|
expect(mockListIssues).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens for an onboarded user with empty source once agents completed enough issues", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/How did you hear about Multica/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("stays closed while agents have completed fewer issues than the threshold", async () => {
|
|
mockAgentDoneTotal.value = 2;
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
// Let the count query settle before asserting the negative.
|
|
await waitFor(() => {
|
|
expect(mockListIssues).toHaveBeenCalled();
|
|
});
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("stays closed outside a workspace context", async () => {
|
|
mockWorkspace.value = null;
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
expect(mockListIssues).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("counts done issues assigned to agents or squads in the current workspace", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
await waitFor(() => {
|
|
expect(mockListIssues).toHaveBeenCalledWith({
|
|
workspace_id: "ws-1",
|
|
statuses: ["done"],
|
|
assignee_types: ["agent", "squad"],
|
|
limit: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("Submit PATCHes the merged questionnaire preserving role / use_case", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: {
|
|
source: [],
|
|
role: "engineer",
|
|
role_skipped: false,
|
|
use_case: ["ship_code", "plan_research"],
|
|
use_case_skipped: false,
|
|
version: 2,
|
|
},
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await user.click(await screen.findByText("Friends or colleagues"));
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
expect(sent.source).toEqual(["friends_colleagues"]);
|
|
expect(sent.source_skipped).toBe(false);
|
|
expect(sent.role).toBe("engineer");
|
|
expect(sent.use_case).toEqual(["ship_code", "plan_research"]);
|
|
expect(sent.version).toBe(2);
|
|
});
|
|
|
|
it("Skip PATCHes source_skipped=true preserving role / use_case", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: {
|
|
source: [],
|
|
role: "founder",
|
|
use_case: ["manage_team"],
|
|
version: 2,
|
|
},
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Skip" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
expect(sent.source).toEqual([]);
|
|
expect(sent.source_skipped).toBe(true);
|
|
expect(sent.role).toBe("founder");
|
|
expect(sent.use_case).toEqual(["manage_team"]);
|
|
});
|
|
|
|
it("treats a legacy single-string source as already answered", () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: "search" },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("picking a second option replaces the first (single-select primary source)", async () => {
|
|
// The modal is a single-select radio. Industry default for HDYHAU
|
|
// is to capture the primary acquisition source, so picking a
|
|
// second option must replace the first — never accumulate.
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await screen.findByText("Friends or colleagues");
|
|
const radios = screen.getAllByRole("radio");
|
|
const friends = radios[0]!;
|
|
const search = radios[1]!;
|
|
|
|
await user.click(friends);
|
|
expect(friends).toHaveAttribute("aria-checked", "true");
|
|
expect(search).toHaveAttribute("aria-checked", "false");
|
|
|
|
// Pick a second option — the first must clear and Submit stays
|
|
// enabled with exactly one pick in the payload.
|
|
await user.click(search);
|
|
expect(friends).toHaveAttribute("aria-checked", "false");
|
|
expect(search).toHaveAttribute("aria-checked", "true");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
// Server schema is still `source: string[]` for back-compat with
|
|
// v2 rows; the client always sends a single-element array.
|
|
expect(sent.source).toEqual(["search"]);
|
|
expect(sent.source).not.toContain("friends_colleagues");
|
|
});
|
|
|
|
it("defers the entrance by ~700ms when the user has not opted into reduced motion", async () => {
|
|
mockPrefersReducedMotion(false);
|
|
// Fake timers from the very start so the 700ms entrance timer is
|
|
// scheduled on the fake clock. The count query itself is
|
|
// promise-based; advanceTimersByTimeAsync flushes the microtasks
|
|
// that deliver its data and let the gate open.
|
|
vi.useFakeTimers();
|
|
try {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
// Flush the count query → gate passes → timer scheduled (t=0).
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(699);
|
|
});
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
});
|
|
expect(
|
|
screen.getByText(/How did you hear about Multica/i),
|
|
).toBeInTheDocument();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not open once the per-user dismiss cap is reached on this browser", () => {
|
|
window.localStorage.setItem("multica.source_backfill.dismiss.u1", "3");
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
expect(mockListIssues).not.toHaveBeenCalled();
|
|
});
|
|
});
|