mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add PostHog customer feedback
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||||
import {
|
||||
__resetGateForTests,
|
||||
refreshAnalyticsGate,
|
||||
} from "../../../apps/api/src/lib/analytics-gate.js";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
|
||||
|
||||
const captureFeedback = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/analytics.js", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
captureFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
let testApp: TestApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(schema.settings).where(eq(schema.settings.key, "analyticsEnabled"));
|
||||
delete process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
captureFeedback.mockClear();
|
||||
__resetGateForTests();
|
||||
});
|
||||
|
||||
describe("POST /api/v1/feedback", () => {
|
||||
it("requires authentication", async () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
payload: { source: "global", sentiment: "great" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects empty feedback payloads", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { source: "global" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("declines capture when analytics is disabled", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "tool_result",
|
||||
surveyId: "tool-result-v1",
|
||||
promptVariant: "inline-v1",
|
||||
sentiment: "great",
|
||||
toolId: "resize",
|
||||
jobStatus: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: false });
|
||||
expect(captureFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts explicit feedback with survey, prompt, friction, and safe error fields", async () => {
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||
await refreshAnalyticsGate();
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-posthog-distinct-id": "distinct-test",
|
||||
},
|
||||
payload: {
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
promptVariant: "settings-card-v1",
|
||||
sentiment: "issue",
|
||||
feedbackType: "bug",
|
||||
message: "S3 setup took guessing.",
|
||||
contactOk: true,
|
||||
contactEmail: "user@example.com",
|
||||
contactName: "Pat",
|
||||
company: "Example Co",
|
||||
installMethod: "docker_compose",
|
||||
usageType: "team_internal",
|
||||
frictionArea: "environment_variables",
|
||||
importantAreas: ["pdf_docs", "batch_workflows"],
|
||||
errorCategory: "processing_error",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||
expect(captureFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
feedback_type: "bug",
|
||||
contact_ok: true,
|
||||
contact_email: "user@example.com",
|
||||
contact_name: "Pat",
|
||||
company: "Example Co",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
}),
|
||||
"distinct-test",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops identifying contact fields when contact consent is not checked", async () => {
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||
await refreshAnalyticsGate();
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
sentiment: "okay",
|
||||
message: "Trying this with the team.",
|
||||
contactOk: false,
|
||||
contactEmail: "user@example.com",
|
||||
contactName: "Pat",
|
||||
company: "Example Co",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||
expect(captureFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contact_ok: false,
|
||||
contact_email: undefined,
|
||||
contact_name: undefined,
|
||||
company: undefined,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid survey ids", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "not-real",
|
||||
sentiment: "great",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "surveyId" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid prompt variants", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
promptVariant: "Inline V1!",
|
||||
sentiment: "great",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "promptVariant" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid friction areas", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
installMethod: "docker",
|
||||
frictionArea: "leaky_file_path",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "frictionArea" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||
import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js";
|
||||
import { feedbackRoutes } from "../../apps/api/src/routes/feedback.js";
|
||||
import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
|
||||
@@ -236,6 +237,9 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// Analytics routes
|
||||
await analyticsRoutes(app);
|
||||
|
||||
// Explicit customer feedback capture
|
||||
await feedbackRoutes(app);
|
||||
|
||||
// API docs (Scalar)
|
||||
await docsRoutes(app);
|
||||
|
||||
|
||||
@@ -221,3 +221,62 @@ describe("trackEvent", () => {
|
||||
await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureFeedback", () => {
|
||||
it("captures feedback_submitted with explicit feedback properties", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.captureFeedback(
|
||||
{
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
sentiment: "issue",
|
||||
feedback_type: "bug",
|
||||
message: "Docs need a complete S3 example.",
|
||||
contact_ok: true,
|
||||
contact_email: "admin@example.com",
|
||||
contact_name: "Pat",
|
||||
company: "Example Co",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
},
|
||||
"distinct-feedback",
|
||||
);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith({
|
||||
distinctId: "distinct-feedback",
|
||||
event: "feedback_submitted",
|
||||
properties: expect.objectContaining({
|
||||
feedback_version: 1,
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
contact_ok: true,
|
||||
contact_email: "admin@example.com",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when analytics is disabled", async () => {
|
||||
bakedConfig.enabled = false;
|
||||
|
||||
await mod.captureFeedback({
|
||||
source: "global",
|
||||
contact_ok: false,
|
||||
message: "A message",
|
||||
});
|
||||
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ANALYTICS_EVENTS", () => {
|
||||
it("has exactly 12 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(12);
|
||||
it("has exactly 13 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("contains the expected keys", () => {
|
||||
@@ -19,6 +19,7 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_PROMPTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED");
|
||||
});
|
||||
|
||||
it("all event values are strings", () => {
|
||||
@@ -43,6 +44,10 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe("ai_bundle_action");
|
||||
});
|
||||
|
||||
it("FEEDBACK_SUBMITTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.FEEDBACK_SUBMITTED).toBe("feedback_submitted");
|
||||
});
|
||||
|
||||
it("all values follow snake_case convention", () => {
|
||||
for (const value of Object.values(ANALYTICS_EVENTS)) {
|
||||
expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminInstallFeedbackCard } from "@/components/feedback/admin-install-feedback-card";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("AdminInstallFeedbackCard", () => {
|
||||
it("does not render when hidden", () => {
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={false}
|
||||
onShare={vi.fn()}
|
||||
onRemindLater={vi.fn()}
|
||||
onDismissForever={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("How was setup?")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders quiet admin install feedback actions", () => {
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={true}
|
||||
onShare={vi.fn()}
|
||||
onRemindLater={vi.fn()}
|
||||
onDismissForever={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("How was setup?")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Share feedback" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Remind me later" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Don't ask again" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls the supplied action callbacks", () => {
|
||||
const onShare = vi.fn();
|
||||
const onRemindLater = vi.fn();
|
||||
const onDismissForever = vi.fn();
|
||||
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={true}
|
||||
onShare={onShare}
|
||||
onRemindLater={onRemindLater}
|
||||
onDismissForever={onDismissForever}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share feedback" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remind me later" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
||||
|
||||
expect(onShare).toHaveBeenCalledTimes(1);
|
||||
expect(onRemindLater).toHaveBeenCalledTimes(1);
|
||||
expect(onDismissForever).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
|
||||
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
||||
|
||||
vi.mock("@/lib/feedback", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
submitFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
submitFeedback.mockClear();
|
||||
});
|
||||
|
||||
describe("FeedbackDialog", () => {
|
||||
it("submits admin install feedback with install-specific fields", async () => {
|
||||
render(<FeedbackDialog open={true} source="admin_installer" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Install method"), {
|
||||
target: { value: "docker_compose" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Use case"), {
|
||||
target: { value: "team_internal" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
|
||||
target: { value: "environment_variables" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("PDF/docs"));
|
||||
fireEvent.click(screen.getByLabelText("Batch workflows"));
|
||||
fireEvent.change(screen.getByLabelText("What should we improve first?"), {
|
||||
target: { value: "The S3 example needs one complete compose snippet." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
promptVariant: "settings-card-v1",
|
||||
feedbackType: "other",
|
||||
message: "The S3 example needs one complete compose snippet.",
|
||||
contactOk: false,
|
||||
contactEmail: undefined,
|
||||
contactName: undefined,
|
||||
company: undefined,
|
||||
installMethod: "docker_compose",
|
||||
usageType: "team_internal",
|
||||
frictionArea: "environment_variables",
|
||||
importantAreas: ["pdf_docs", "batch_workflows"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit contact fields without contact consent", async () => {
|
||||
render(<FeedbackDialog open={true} source="global" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Feedback"), {
|
||||
target: { value: "I want a keyboard shortcut for download." },
|
||||
});
|
||||
expect(screen.queryByPlaceholderText("Email (optional)")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
contactOk: false,
|
||||
contactEmail: undefined,
|
||||
contactName: undefined,
|
||||
company: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
|
||||
|
||||
const NOW = new Date("2026-01-15T00:00:00Z").getTime();
|
||||
|
||||
describe("shouldShowInstallFeedbackCard", () => {
|
||||
it("shows only for admins after analytics config is loaded and enabled", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "user",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: false,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: false,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stays hidden after submit or permanent dismiss", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.submittedAt": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.dismissedAt": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honors snooze until the stored timestamp expires", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.snoozedUntil": "2026-01-16T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.snoozedUntil": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolFeedbackPrompt } from "@/components/feedback/tool-feedback-prompt";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
||||
const storageMap = vi.hoisted(() => new Map<string, string>());
|
||||
const localStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => storageMap.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => storageMap.delete(key)),
|
||||
clear: vi.fn(() => storageMap.clear()),
|
||||
key: vi.fn((_index: number) => null),
|
||||
get length() {
|
||||
return storageMap.size;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feedback", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
submitFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
localStorage.clear();
|
||||
submitFeedback.mockClear();
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
localStorageMock.clear.mockClear();
|
||||
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-15T00:00:00Z").getTime());
|
||||
useAnalyticsStore.setState({
|
||||
configLoaded: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://us.i.posthog.com",
|
||||
sentryDsn: "",
|
||||
sampleRate: 1,
|
||||
instanceId: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ToolFeedbackPrompt", () => {
|
||||
it("renders when analytics feedback capture is enabled", () => {
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.getByText("How did this tool work?")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Worked well" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render when analytics is disabled", () => {
|
||||
useAnalyticsStore.setState({
|
||||
configLoaded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
},
|
||||
});
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits quick positive feedback with survey and prompt metadata", async () => {
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Worked well" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "tool_result",
|
||||
surveyId: "tool-result-v1",
|
||||
promptVariant: "inline-v1",
|
||||
sentiment: "great",
|
||||
feedbackType: "other",
|
||||
toolId: "resize",
|
||||
jobStatus: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText("Thanks for the signal.")).toBeDefined();
|
||||
expect(localStorage.getItem("snapotter-feedback-last-prompt-at")).toBeTruthy();
|
||||
expect(localStorage.getItem("snapotter-feedback-tool-prompt:resize")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is suppressed during the 30-day global cooldown", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-last-prompt-at",
|
||||
String(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("is suppressed during the 90-day per-tool cooldown", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-tool-prompt:resize",
|
||||
String(Date.now() - 89 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows after the global and per-tool cooldowns have both elapsed", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-last-prompt-at",
|
||||
String(Date.now() - 31 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-tool-prompt:resize",
|
||||
String(Date.now() - 91 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.getByText("How did this tool work?")).toBeDefined();
|
||||
});
|
||||
|
||||
it("supports Don't ask again suppression", () => {
|
||||
const { unmount } = render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
||||
|
||||
expect(localStorage.getItem("snapotter-feedback-prompts-disabled")).toBe("true");
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
|
||||
unmount();
|
||||
render(<ToolFeedbackPrompt toolId="convert" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user