Files
formbricks/apps/web/modules/hub/service.test.ts
T
Dhruwang Jariwala 939fedfca4 feat: Formbricks 5 (#8017)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-15 16:43:27 +00:00

462 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from "vitest";
import { createCacheKey } from "@formbricks/cache";
import FormbricksHub from "@formbricks/hub";
import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
semanticSearchFeedbackRecords,
updateFeedbackRecord,
} from "./service";
import type { FeedbackRecordCreateParams } from "./types";
vi.mock("@formbricks/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("@formbricks/hub", () => ({
default: {
APIError: class APIError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
},
},
}));
vi.mock("./hub-client", () => ({
getHubClient: vi.fn(),
}));
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(async (fn: () => Promise<unknown>) => await fn()),
},
}));
const { getHubClient } = await import("./hub-client");
const { cache } = await import("@/lib/cache");
const sampleInput: FeedbackRecordCreateParams = {
field_id: "el-1",
field_type: "rating",
source_type: "formbricks_survey",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
value_number: 5,
collected_at: "2026-02-24T10:00:00.000Z",
submission_id: "sub-1",
tenant_id: "tenant-1",
};
describe("hub service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createFeedbackRecord", () => {
test("returns error result when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
});
});
test("returns data when client.create succeeds", async () => {
const created = { id: "hub-1", ...sampleInput };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create: vi.fn().mockResolvedValue(created) },
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.error).toBeNull();
expect(result.data).toEqual(created);
});
test("returns error result when client.create throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create: vi.fn().mockRejectedValue(new Error("Network error")) },
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Network error" });
});
test("reads status from a foreign error class (simulates dual module scope)", async () => {
// Simulates the SDK being loaded into a different module scope under Next dev/Turbopack:
// the thrown error is NOT instanceof the FormbricksHub.APIError reference captured in service.ts.
class ForeignConflictError extends Error {
readonly status = 409;
}
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
create: vi.fn().mockRejectedValue(new ForeignConflictError("duplicate submission_id")),
},
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 409, message: "duplicate submission_id" });
});
});
describe("listFeedbackRecords", () => {
test("returns error result when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await listFeedbackRecords({ tenant_id: "env-1" });
expect(result.data).toBeNull();
expect(result.error).toMatchObject({
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
});
});
test("returns data when client.list succeeds", async () => {
const listResponse = {
data: [{ id: "rec-1", field_id: "el-1", field_type: "rating" }],
total: 1,
limit: 50,
offset: 0,
};
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { list: vi.fn().mockResolvedValue(listResponse) },
} as any);
const result = await listFeedbackRecords({ tenant_id: "env-1", limit: 50 });
expect(result.error).toBeNull();
expect(result.data).toEqual(listResponse);
});
test("returns error result when client.list throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { list: vi.fn().mockRejectedValue(new Error("Network error")) },
} as any);
const result = await listFeedbackRecords({ tenant_id: "env-1" });
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "Network error" });
});
});
describe("retrieveFeedbackRecord", () => {
test("returns error when client is null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data on success", async () => {
const record = { id: "rec-1", field_id: "f1" };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { retrieve: vi.fn().mockResolvedValue(record) },
} as any);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toEqual(record);
expect(result.error).toBeNull();
});
test("returns error on throw", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { retrieve: vi.fn().mockRejectedValue(new Error("Not found")) },
} as any);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Not found" });
});
});
describe("semanticSearchFeedbackRecords", () => {
test("returns error result when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await semanticSearchFeedbackRecords({
tenant_id: "env-1",
query: "slow checkout",
});
expect(result.data).toBeNull();
expect(result.error).toMatchObject({
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
});
});
test("returns data when client.search.performSemanticSearch succeeds", async () => {
const searchResponse = {
data: [
{
feedback_record_id: "018e1234-5678-9abc-def0-123456789abc",
score: 0.91,
field_label: "What can we improve?",
value_text: "Checkout feels slow.",
},
],
limit: 10,
};
const performSemanticSearch = vi.fn().mockResolvedValue(searchResponse);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { search: { performSemanticSearch } },
} as any);
const input = {
tenant_id: "env-1",
query: "slow checkout",
limit: 10,
min_score: 0.7,
};
const result = await semanticSearchFeedbackRecords(input);
expect(result.error).toBeNull();
expect(result.data).toEqual(searchResponse);
expect(performSemanticSearch).toHaveBeenCalledWith(input);
});
test("returns error with status when client.search.performSemanticSearch throws APIError", async () => {
const apiError = new (FormbricksHub.APIError as any)("Embeddings are not configured", 503);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
search: { performSemanticSearch: vi.fn().mockRejectedValue(apiError) },
},
} as any);
const result = await semanticSearchFeedbackRecords({
tenant_id: "env-1",
query: "slow checkout",
});
expect(result.data).toBeNull();
expect(result.error).toMatchObject({
status: 503,
message: "Embeddings are not configured",
});
});
test("returns error result when call throws non-API error", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
search: { performSemanticSearch: vi.fn().mockRejectedValue(new Error("Network error")) },
},
} as any);
const result = await semanticSearchFeedbackRecords({
tenant_id: "env-1",
query: "slow checkout",
});
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "Network error" });
});
});
describe("updateFeedbackRecord", () => {
test("returns error when client is null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data on success", async () => {
const updated = { id: "rec-1", value_text: "new" };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { update: vi.fn().mockResolvedValue(updated) },
} as any);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toEqual(updated);
expect(result.error).toBeNull();
});
test("returns error on throw", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { update: vi.fn().mockRejectedValue(new Error("Forbidden")) },
} as any);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Forbidden" });
});
});
describe("deleteFeedbackRecord", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue(undefined);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: deleteSpy },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(deleteSpy).toHaveBeenCalledWith("rec-1");
expect(result.data).toEqual({ deleted: true });
expect(result.error).toBeNull();
});
test("returns error when client.delete throws APIError", async () => {
const apiError = new (FormbricksHub as any).APIError("Forbidden", 403);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 403, message: "Forbidden" });
});
test("returns error when client.delete throws non-API error", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(new Error("network")) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await createFeedbackRecordsBatch([sampleInput, { ...sampleInput, field_id: "el-2" }]);
expect(result.results).toHaveLength(2);
result.results.forEach((r) => {
expect(r.data).toBeNull();
expect(r.error?.message).toContain("HUB_API_KEY is not set");
});
});
test("returns results per input when client creates succeed", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
create: vi
.fn()
.mockImplementation((input: FeedbackRecordCreateParams) =>
Promise.resolve({ id: `hub-${input.field_id}`, ...input })
),
},
} as any);
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
const result = await createFeedbackRecordsBatch(inputs);
expect(result.results).toHaveLength(2);
expect(result.results[0].data).toMatchObject({ field_id: "el-1" });
expect(result.results[0].error).toBeNull();
expect(result.results[1].data).toMatchObject({ field_id: "el-2" });
expect(result.results[1].error).toBeNull();
});
test("returns mixed results when some creates fail", async () => {
const create = vi
.fn()
.mockResolvedValueOnce({ id: "hub-1", ...sampleInput })
.mockRejectedValueOnce(new Error("Rate limited"));
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create },
} as any);
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
const result = await createFeedbackRecordsBatch(inputs);
expect(result.results).toHaveLength(2);
expect(result.results[0].data).not.toBeNull();
expect(result.results[0].error).toBeNull();
expect(result.results[1].data).toBeNull();
expect(result.results[1].error).toMatchObject({ message: "Rate limited" });
});
});
describe("getFeedbackRecordTenant", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await getFeedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8");
expect(result).toEqual({
data: null,
error: {
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
},
});
expect(vi.mocked(cache.withCache)).not.toHaveBeenCalled();
});
test("returns cached tenant data when retrieve succeeds", async () => {
const retrieve = vi.fn().mockResolvedValue({
id: "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8",
tenant_id: "clxx1234567890123456789012",
});
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { retrieve },
} as any);
const result = await getFeedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8");
expect(result).toEqual({
data: { tenantId: "clxx1234567890123456789012" },
error: null,
});
expect(vi.mocked(cache.withCache)).toHaveBeenCalledOnce();
expect(vi.mocked(cache.withCache)).toHaveBeenCalledWith(
expect.any(Function),
createCacheKey.hub.feedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8"),
60_000
);
expect(retrieve).toHaveBeenCalledWith("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8");
});
test("returns error result when retrieve fails", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
retrieve: vi.fn().mockRejectedValue(new Error("Network error")),
},
} as any);
const result = await getFeedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8");
expect(result).toEqual({
data: null,
error: {
status: 0,
message: "Network error",
detail: "Network error",
},
});
});
});
});