mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 15:24:22 +00:00
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { Survey } from "posthog-js";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type SurveyCallback = (surveys: Survey[]) => void;
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
isCloud: vi.fn(),
|
|
useRuntimeConfig: vi.fn(),
|
|
init: vi.fn(),
|
|
onSurveysLoaded: vi.fn(),
|
|
capture: vi.fn(),
|
|
moduleLoaded: vi.fn(),
|
|
// Mirrors the singleton's `__loaded` flag: true when a PostHog instance
|
|
// already exists (as on Prowler Cloud, initialized in app/providers.tsx).
|
|
loaded: false,
|
|
}));
|
|
|
|
vi.mock("@/lib/shared/env", () => ({ isCloud: mocks.isCloud }));
|
|
vi.mock("@/hooks/use-runtime-config", () => ({
|
|
useRuntimeConfig: mocks.useRuntimeConfig,
|
|
}));
|
|
vi.mock("posthog-js", () => {
|
|
mocks.moduleLoaded();
|
|
return {
|
|
default: {
|
|
get __loaded() {
|
|
return mocks.loaded;
|
|
},
|
|
init: mocks.init,
|
|
onSurveysLoaded: mocks.onSurveysLoaded,
|
|
capture: mocks.capture,
|
|
},
|
|
};
|
|
});
|
|
|
|
const POSTHOG_KEY = "phc_key";
|
|
|
|
const SURVEY_FIXTURE = {
|
|
id: "survey-123",
|
|
name: "Prowler Feedback",
|
|
type: "api",
|
|
questions: [
|
|
{
|
|
id: "q-1",
|
|
type: "open",
|
|
question: "What could we do better?",
|
|
description:
|
|
"Is there anything we could do to make your experience better?",
|
|
},
|
|
],
|
|
appearance: {
|
|
placeholder: "Type your answer here",
|
|
submitButtonText: "Submit answer",
|
|
thankYouMessageHeader: "Thanks for the feedback!",
|
|
thankYouMessageDescription: "The Prowler team reads every response.",
|
|
},
|
|
} as unknown as Survey;
|
|
|
|
// Feed the given surveys to the component through the posthog callback the way
|
|
// the real SDK does once the definitions have loaded.
|
|
const provideSurveys = (surveys: Survey[]): void => {
|
|
mocks.onSurveysLoaded.mockImplementation((callback: SurveyCallback) => {
|
|
callback(surveys);
|
|
return () => {};
|
|
});
|
|
};
|
|
|
|
const renderSurvey = async () => {
|
|
const { FeedbackSurvey } = await import("./feedback-survey");
|
|
return render(<FeedbackSurvey />);
|
|
};
|
|
|
|
describe("FeedbackSurvey", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.resetAllMocks();
|
|
mocks.loaded = false;
|
|
mocks.isCloud.mockReturnValue(true);
|
|
mocks.useRuntimeConfig.mockReturnValue({
|
|
cloudEnabled: true,
|
|
posthogEnabled: true,
|
|
posthogKey: POSTHOG_KEY,
|
|
posthogHost: "https://eu.posthog.com",
|
|
});
|
|
provideSurveys([SURVEY_FIXTURE]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("renders the feedback trigger when Cloud and the survey is available", async () => {
|
|
// When
|
|
await renderSurvey();
|
|
|
|
// Then
|
|
const trigger = await screen.findByRole("button", {
|
|
name: "Give feedback",
|
|
});
|
|
expect(trigger).toBeVisible();
|
|
expect(trigger).toHaveTextContent("Feedback");
|
|
});
|
|
|
|
it("ignores a same-name non-API survey and selects the API survey", async () => {
|
|
// Given - PostHog can return multiple survey types with the same name.
|
|
const popoverSurvey = {
|
|
...SURVEY_FIXTURE,
|
|
id: "survey-popover",
|
|
type: "popover",
|
|
questions: [
|
|
{
|
|
id: "q-popover",
|
|
type: "open",
|
|
question: "This popover must not render",
|
|
},
|
|
],
|
|
} as unknown as Survey;
|
|
provideSurveys([popoverSurvey, SURVEY_FIXTURE]);
|
|
const user = userEvent.setup();
|
|
|
|
// When
|
|
await renderSurvey();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Give feedback" }),
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
await screen.findByRole("heading", { name: "What could we do better?" }),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.queryByRole("heading", { name: "This popover must not render" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("opens a visible feedback form with the fetched question, description, placeholder, and submit copy", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
await renderSurvey();
|
|
const trigger = await screen.findByRole("button", {
|
|
name: "Give feedback",
|
|
});
|
|
|
|
// Then - the form is not present until the user opens it
|
|
expect(
|
|
screen.queryByRole("heading", { name: "What could we do better?" }),
|
|
).not.toBeInTheDocument();
|
|
|
|
// When
|
|
await user.click(trigger);
|
|
|
|
// Then - the user actually sees the survey copy from the definition
|
|
expect(
|
|
await screen.findByRole("heading", { name: "What could we do better?" }),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.getByText(
|
|
"Is there anything we could do to make your experience better?",
|
|
),
|
|
).toBeVisible();
|
|
const input = screen.getByPlaceholderText("Type your answer here");
|
|
expect(input).toBeVisible();
|
|
expect(screen.getByRole("button", { name: "Submit answer" })).toBeVisible();
|
|
});
|
|
|
|
it("captures 'survey shown' when the form opens", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
await renderSurvey();
|
|
const trigger = await screen.findByRole("button", {
|
|
name: "Give feedback",
|
|
});
|
|
|
|
// When
|
|
await user.click(trigger);
|
|
|
|
// Then
|
|
await waitFor(() =>
|
|
expect(mocks.capture).toHaveBeenCalledWith("survey shown", {
|
|
$survey_id: "survey-123",
|
|
$survey_name: "Prowler Feedback",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("captures 'survey sent' with modern and legacy response keys and then thanks the user", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
await renderSurvey();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Give feedback" }),
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Type your answer here"),
|
|
"Add dark mode",
|
|
);
|
|
|
|
// When
|
|
await user.click(screen.getByRole("button", { name: "Submit answer" }));
|
|
|
|
// Then - the capture carries the survey identity, per-question payload, and
|
|
// BOTH the modern per-question key and the legacy first-question key.
|
|
await waitFor(() =>
|
|
expect(mocks.capture).toHaveBeenCalledWith("survey sent", {
|
|
$survey_id: "survey-123",
|
|
$survey_name: "Prowler Feedback",
|
|
$survey_questions: [
|
|
{
|
|
id: "q-1",
|
|
question: "What could we do better?",
|
|
response: "Add dark mode",
|
|
},
|
|
],
|
|
"$survey_response_q-1": "Add dark mode",
|
|
$survey_response: "Add dark mode",
|
|
}),
|
|
);
|
|
|
|
// Then - the user sees the thank-you copy from the definition
|
|
expect(await screen.findByText("Thanks for the feedback!")).toBeVisible();
|
|
expect(
|
|
screen.getByText("The Prowler team reads every response."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("captures 'survey dismissed' when the form is closed without submitting", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
await renderSurvey();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Give feedback" }),
|
|
);
|
|
await screen.findByRole("heading", { name: "What could we do better?" });
|
|
|
|
// When - the user closes the popover without answering
|
|
await user.keyboard("{Escape}");
|
|
|
|
// Then
|
|
await waitFor(() =>
|
|
expect(mocks.capture).toHaveBeenCalledWith("survey dismissed", {
|
|
$survey_id: "survey-123",
|
|
$survey_name: "Prowler Feedback",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not dismiss after a successful submit when the form is closed", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
await renderSurvey();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Give feedback" }),
|
|
);
|
|
await user.type(
|
|
screen.getByPlaceholderText("Type your answer here"),
|
|
"Great tool",
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Submit answer" }));
|
|
await screen.findByText("Thanks for the feedback!");
|
|
|
|
// When - the user closes the thank-you popover
|
|
await user.keyboard("{Escape}");
|
|
|
|
// Then - a submitted survey is never also reported as dismissed
|
|
await waitFor(() =>
|
|
expect(mocks.capture).toHaveBeenCalledWith(
|
|
"survey sent",
|
|
expect.anything(),
|
|
),
|
|
);
|
|
expect(mocks.capture).not.toHaveBeenCalledWith(
|
|
"survey dismissed",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("initializes PostHog once with the runtime config, then loads surveys, when Cloud with a key and host and no existing instance", async () => {
|
|
// Given - no PostHog instance exists yet (self-hosted opt-in path). Init is
|
|
// deferred to this component effect (post-hydration) to avoid a hydration
|
|
// mismatch, using the runtime public config island.
|
|
mocks.loaded = false;
|
|
|
|
// When
|
|
await renderSurvey();
|
|
|
|
// Then
|
|
await waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1));
|
|
expect(mocks.init.mock.calls[0][0]).toBe(POSTHOG_KEY);
|
|
expect(mocks.init.mock.calls[0][1]).toMatchObject({
|
|
api_host: "https://eu.posthog.com",
|
|
ui_host: "https://eu.posthog.com",
|
|
autocapture: false,
|
|
capture_pageview: false,
|
|
capture_pageleave: false,
|
|
});
|
|
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
|
});
|
|
|
|
it("consumes the already-initialized Cloud instance without re-initializing", async () => {
|
|
// Given - on Prowler Cloud the singleton is already initialized
|
|
// (app/providers.tsx), so __loaded is true.
|
|
mocks.loaded = true;
|
|
|
|
// When
|
|
await renderSurvey();
|
|
|
|
// Then - it reuses Cloud's instance: no re-init, but surveys still load and
|
|
// the trigger renders.
|
|
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
|
expect(mocks.init).not.toHaveBeenCalled();
|
|
expect(
|
|
await screen.findByRole("button", { name: "Give feedback" }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("does not initialize or load surveys when the runtime config is empty", async () => {
|
|
// Given - the runtime config island has not provided PostHog settings
|
|
mocks.loaded = false;
|
|
mocks.useRuntimeConfig.mockReturnValue({
|
|
cloudEnabled: false,
|
|
posthogEnabled: false,
|
|
posthogKey: null,
|
|
posthogHost: null,
|
|
});
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then - no init, no survey fetch, nothing rendered
|
|
expect(mocks.init).not.toHaveBeenCalled();
|
|
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
|
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it("renders nothing and touches no PostHog surface when PostHog is disabled, even with a key and host present", async () => {
|
|
// Given - the integration is off with a stale key/host still present
|
|
mocks.loaded = false;
|
|
mocks.useRuntimeConfig.mockReturnValue({
|
|
cloudEnabled: true,
|
|
posthogEnabled: false,
|
|
posthogKey: POSTHOG_KEY,
|
|
posthogHost: "https://eu.posthog.com",
|
|
});
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Give feedback" }),
|
|
).not.toBeInTheDocument();
|
|
expect(mocks.init).not.toHaveBeenCalled();
|
|
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
|
expect(mocks.capture).not.toHaveBeenCalled();
|
|
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders nothing and touches no PostHog surface when not Cloud, even with a survey and key present", async () => {
|
|
// Given - OSS: isCloud() is the primary guard, even with a key and instance
|
|
mocks.isCloud.mockReturnValue(false);
|
|
mocks.loaded = true;
|
|
provideSurveys([SURVEY_FIXTURE]);
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then - no init, no trigger, no survey fetch, no capture, regardless of config
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Give feedback" }),
|
|
).not.toBeInTheDocument();
|
|
expect(mocks.init).not.toHaveBeenCalled();
|
|
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
|
expect(mocks.capture).not.toHaveBeenCalled();
|
|
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders nothing when Cloud but the named survey is not available", async () => {
|
|
// Given
|
|
provideSurveys([]);
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then
|
|
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
expect(mocks.capture).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders nothing and never crashes when the matched survey has no questions", async () => {
|
|
// Given - a definition with an empty questions array (regression: submit
|
|
// used to read questions[0].question unguarded and could crash).
|
|
const questionlessSurvey = {
|
|
...SURVEY_FIXTURE,
|
|
questions: [],
|
|
} as unknown as Survey;
|
|
provideSurveys([questionlessSurvey]);
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then - no trigger renders, so the form (and its question access) is
|
|
// unreachable: nothing to interact with, nothing throws.
|
|
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Give feedback" }),
|
|
).not.toBeInTheDocument();
|
|
expect(mocks.capture).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders nothing when the first question is not an open-text question", async () => {
|
|
// Given - an editor changed the question type in the PostHog dashboard to a
|
|
// non-open kind; the free-text form only matches `open`.
|
|
const ratingSurvey = {
|
|
...SURVEY_FIXTURE,
|
|
questions: [
|
|
{
|
|
id: "q-1",
|
|
type: "rating",
|
|
question: "How would you rate Prowler?",
|
|
display: "number",
|
|
scale: 5,
|
|
lowerBoundLabel: "Bad",
|
|
upperBoundLabel: "Great",
|
|
},
|
|
],
|
|
} as unknown as Survey;
|
|
provideSurveys([ratingSurvey]);
|
|
|
|
// When
|
|
const view = await renderSurvey();
|
|
|
|
// Then - feature safely off: no trigger, no malformed response path
|
|
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
|
expect(view.container).toBeEmptyDOMElement();
|
|
expect(
|
|
screen.queryByRole("button", { name: "Give feedback" }),
|
|
).not.toBeInTheDocument();
|
|
expect(mocks.capture).not.toHaveBeenCalled();
|
|
});
|
|
});
|