Compare commits

..

7 Commits

Author SHA1 Message Date
Matthias Nannt 5ca3aecd6d fix sonarqube issue 2025-05-07 13:30:44 +02:00
Matthias Nannt c4aa83492c chore: remove data migration from docker runtime image 2025-05-07 13:14:52 +02:00
Piyush Jain b9d62f6af2 fix: pin version 1 of helmfile actions (#5691) 2025-05-07 09:45:57 +02:00
Matti Nannt f7ac38953b fix: infinite redirect issue (#5693)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 09:40:45 +02:00
Anshuman Pandey 6441c0aa31 fix: moves storage api management endpoint to use payload instead of … (#5348)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-07 04:55:58 +00:00
victorvhs017 16479eb6cf chore: add tests to environments path - part 2 (#5667) 2025-05-07 02:32:03 +02:00
Matti Nannt 69472c21c2 chore: simplify vite config for better coverage report (#5687) 2025-05-07 01:42:21 +02:00
53 changed files with 5724 additions and 450 deletions
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release'
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
required: true
type: string
REPOSITORY:
@@ -67,7 +67,7 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
if: inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -75,6 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -84,13 +85,14 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
if: inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
+1 -1
View File
@@ -30,5 +30,5 @@ jobs:
- docker-build
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
+2 -58
View File
@@ -76,13 +76,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
RUN addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
WORKDIR /home/nextjs
@@ -103,48 +97,6 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
COPY --from=installer /app/apps/web/public ./apps/web/public
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
@@ -158,12 +110,4 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
CMD ["node", "apps/web/server.js"]
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
@@ -264,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -0,0 +1,456 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AddIntegrationModal } from "./AddIntegrationModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
() => ({
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
<div>
<label htmlFor="base">Base</label>
<select
id="base"
defaultValue={defaultValue}
onChange={(e) => {
control._mockOnChange({ target: { name: "base", value: e.target.value } });
setValue("table", ""); // Reset table when base changes
fetchTable(e.target.value);
}}>
<option value="">Select Base</option>
{airtableArray.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
fetchTables: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value, _locale) => value?.default || value || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey, _locale) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}) => (
<div data-testid="additional-settings">
<input
type="checkbox"
data-testid="include-variables"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-hidden"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-metadata"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-createdat"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
open ? (
<div data-testid="modal">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props) => <img alt="test" {...props} />,
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
}));
// Mock the Select component used for Table and Survey selections
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children }) => (
// Render children, assuming Controller passes props to the Trigger/Value
// The actual select logic will be handled by the mocked Controller/field
// We need to simulate the structure expected by the Controller render prop
<div>{children}</div>
),
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
SelectItem: ({ children, value, ...props }) => (
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
// For Controller, the value change is handled by field.onChange directly
<div data-value={value} {...props}>
{children}
</div>
),
}));
// Mock react-hook-form Controller to render a simple select
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
let fields = {};
const mockReset = vi.fn((values) => {
fields = values || {}; // Reset fields, optionally with new values
});
return {
...actual,
useForm: vi.fn((options) => {
fields = options?.defaultValues || {};
const mockControlOnChange = (event) => {
if (event && event.target) {
fields[event.target.name] = event.target.value;
}
};
return {
handleSubmit: (fn) => (e) => {
e?.preventDefault();
fn(fields);
},
control: {
_mockOnChange: mockControlOnChange,
// Add other necessary control properties if needed
register: vi.fn(),
unregister: vi.fn(),
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
_options: {},
_proxyFormState: {
isDirty: false,
isValidating: false,
dirtyFields: {},
touchedFields: {},
errors: {},
},
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
_updateFormState: vi.fn(),
_updateFieldArray: vi.fn(),
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
_getWatch: vi.fn(),
_subjects: {
watch: { subscribe: vi.fn() },
array: { subscribe: vi.fn() },
state: { subscribe: vi.fn() },
},
_getDirty: vi.fn(),
_reset: vi.fn(),
_removeUnmounted: vi.fn(),
},
watch: (name) => fields[name],
setValue: (name, value) => {
fields[name] = value;
},
reset: mockReset,
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
getValues: (name) => (name ? fields[name] : fields),
};
}),
Controller: ({ name, defaultValue }) => {
// Initialize field value if not already set by reset/defaultValues
if (fields[name] === undefined && defaultValue !== undefined) {
fields[name] = defaultValue;
}
const field = {
onChange: (valueOrEvent) => {
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
fields[name] = value;
// Re-render might be needed here in a real scenario, but testing library handles it
},
onBlur: vi.fn(),
value: fields[name],
name: name,
ref: vi.fn(),
};
// Find the corresponding label to associate with the select
const labelId = name; // Assuming label 'for' matches field name
const labelText =
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
// Render a simple select element instead of the complex component
// This makes interaction straightforward with userEvent.selectOptions
return (
<>
{/* The actual label is rendered outside the Controller in the component */}
<select
id={labelId}
aria-label={labelText} // Use aria-label for accessibility in tests
{...field} // Spread field props
defaultValue={defaultValue} // Pass defaultValue
>
{/* Need to dynamically get options based on context, simplified here */}
{name === "table" &&
mockTables.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
{name === "survey" &&
mockSurveys.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</>
);
},
reset: mockReset,
};
});
const environmentId = "test-env-id";
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
questions: [
{ id: "q1", headline: { default: "Question 1" } },
{ id: "q2", headline: { default: "Question 2" } },
],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
variables: { enabled: true, fieldIds: ["var1"] },
} as any,
{
id: "survey2",
name: "Survey 2",
questions: [{ id: "q3", headline: { default: "Question 3" } }],
hiddenFields: { enabled: false },
variables: { enabled: false },
} as any,
];
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base 1" },
{ id: "base2", name: "Base 2" },
];
const mockAirtableIntegration: TIntegrationAirtable = {
id: "integration1",
type: "airtable",
environmentId,
config: {
key: { access_token: "abc" } as TIntegrationAirtableCredential,
email: "test@test.com",
data: [],
},
};
const mockTables: TIntegrationAirtableTables["tables"] = [
{ id: "table1", name: "Table 1" },
{ id: "table2", name: "Table 2" },
];
const mockSetOpenWithStates = vi.fn();
const mockRouterRefresh = vi.fn();
describe("AddIntegrationModal", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
});
afterEach(() => {
cleanup();
});
test("renders in add mode correctly", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
expect(screen.getByLabelText("Base")).toBeInTheDocument();
// Use getByLabelText for the mocked selects
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
});
test("shows 'No Base Found' error when airtableArray is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={[]}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByTestId("alert-title")).toHaveTextContent(
"environments.integrations.airtable.no_bases_found"
);
});
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={[]}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
});
test("fetches and displays tables when a base is selected", async () => {
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
const baseSelect = screen.getByLabelText("Base");
await userEvent.selectOptions(baseSelect, "base1");
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
await waitFor(() => {
// Use getByLabelText (mocked select)
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
expect(tableSelect).toBeEnabled();
// Check options within the mocked select
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
});
});
test("handles deletion in edit mode", async () => {
const initialData: TIntegrationAirtableConfigData = {
baseId: "base1",
tableId: "table1",
surveyId: "survey1",
questionIds: ["q1"],
questions: "common.selected_questions",
tableName: "Table 1",
surveyName: "Survey 1",
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: true,
};
const integrationWithData = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, data: [initialData] },
};
const defaultData = { ...initialData, index: 0 } as any;
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={integrationWithData}
isEditMode={true}
defaultData={defaultData}
/>
);
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
// Click delete
await userEvent.click(screen.getByText("common.delete"));
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
// Expect data array to be empty after deletion
expect(submittedData.config.data).toHaveLength(0);
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalled();
});
test("handles cancel button click", async () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
await userEvent.click(screen.getByText("common.cancel"));
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,134 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "./AirtableWrapper";
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
() => ({
ManageIntegration: ({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
Connect
</button>
</div>
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/airtableLogo.svg", () => ({
default: "airtable-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys = [];
const airtableArray = [];
const locale = "en-US" as const;
const baseProps = {
environmentId,
airtableArray,
surveys,
environment,
webAppUrl,
locale,
};
describe("AirtableWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected (no integration)", () => {
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
});
test("renders ConnectIntegration when not connected (integration without key)", () => {
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration disabled when isEnabled is false", () => {
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://airtable.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
test("renders ManageIntegration when connected", () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
// Initially, ManageIntegration is shown
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Simulate disconnection via ManageIntegration's button
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
await userEvent.click(disconnectButton);
// Now, ConnectIntegration should be shown
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,125 @@
import { cleanup, render, screen } from "@testing-library/react";
import { useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import { IntegrationModalInputs } from "./AddIntegrationModal";
import { BaseSelectDropdown } from "./BaseSelectDropdown";
// Mock UI components
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
}));
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, disabled, defaultValue }) => (
<select
data-testid="base-select"
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled}
defaultValue={defaultValue}>
{children}
</select>
),
SelectTrigger: ({ children }) => <div>{children}</div>,
SelectValue: () => <span>SelectValueMock</span>,
SelectContent: ({ children }) => <div>{children}</div>,
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
}));
// Mock react-hook-form's Controller specifically
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
// Keep the actual useForm
const originalUseForm = actual.useForm;
// Mock Controller
const MockController = ({ name, _, render, defaultValue }) => {
// Minimal mock: call render with a basic field object
const field = {
onChange: vi.fn(), // Simple spy for field.onChange
onBlur: vi.fn(),
value: defaultValue, // Use defaultValue passed to Controller
name: name,
ref: vi.fn(),
};
// The component passes the render prop result to the actual Select component
return render({ field });
};
return {
...actual,
useForm: originalUseForm, // Use the actual useForm
Controller: MockController, // Use the mocked Controller
};
});
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base One" },
{ id: "base2", name: "Base Two" },
];
const mockFetchTable = vi.fn();
// Use a wrapper component that utilizes the actual useForm
const renderComponent = (
isLoading = false,
defaultValue: string | undefined = undefined,
airtableArray = mockAirtableArray
) => {
const Component = () => {
// Now uses the actual useForm because Controller is mocked separately
const { control, setValue } = useForm<IntegrationModalInputs>({
defaultValues: { base: defaultValue },
});
return (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={mockFetchTable} // The spy
airtableArray={airtableArray}
setValue={setValue} // Actual RHF setValue
defaultValue={defaultValue}
/>
);
};
return render(<Component />);
};
describe("BaseSelectDropdown", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the label and select trigger", () => {
renderComponent();
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
expect(screen.getByTestId("base-select")).toBeInTheDocument();
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
});
test("renders options from airtableArray", () => {
renderComponent();
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
expect(screen.getByText("Base One")).toBeInTheDocument();
expect(screen.getByText("Base Two")).toBeInTheDocument();
});
test("disables the select when isLoading is true", () => {
renderComponent(true);
expect(screen.getByTestId("base-select")).toBeDisabled();
});
test("enables the select when isLoading is false", () => {
renderComponent(false);
expect(screen.getByTestId("base-select")).toBeEnabled();
});
test("renders correctly with empty airtableArray", () => {
renderComponent(false, undefined, []);
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(0);
});
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
import { authorize, fetchTables } from "./airtable";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
global.fetch = vi.fn();
const environmentId = "test-env-id";
const baseId = "test-base-id";
const apiHost = "http://localhost:3000";
describe("Airtable Library", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("fetchTables", () => {
test("should fetch tables successfully", async () => {
const mockTables: TIntegrationAirtableTables = {
tables: [
{ id: "tbl1", name: "Table 1" },
{ id: "tbl2", name: "Table 2" },
],
};
const mockResponse = {
ok: true,
json: async () => ({ data: mockTables }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const tables = await fetchTables(environmentId, baseId);
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
cache: "no-store",
});
expect(tables).toEqual(mockTables);
});
});
describe("authorize", () => {
test("should return authUrl successfully", async () => {
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
const mockResponse = {
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const authUrl = await authorize(environmentId, apiHost);
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(authUrl).toBe(mockAuthUrl);
});
test("should throw error and log when fetch fails", async () => {
const errorText = "Failed to fetch";
const mockResponse = {
ok: false,
text: async () => errorText,
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
});
});
});
@@ -0,0 +1,217 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
vi.mock("@/lib/airtable/service");
let mockAirtableClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get AIRTABLE_CLIENT_ID() {
return mockAirtableClientId;
},
WEBAPP_URL: "http://localhost:3000",
IS_PRODUCTION: true,
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service");
vi.mock("@/lib/utils/locale");
vi.mock("@/modules/environments/lib/utils");
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation");
const mockEnvironmentId = "test-env-id";
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
const mockAirtableIntegration: TIntegrationAirtable = {
type: "airtable",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
data: [],
email: "test@example.com",
},
environmentId: mockEnvironmentId,
id: "int_airtable_123",
};
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
const mockLocale = "en-US";
const props = {
params: {
environmentId: mockEnvironmentId,
},
};
describe("Airtable Integration Page", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects if user is readOnly", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as unknown as TEnvironmentAuth);
await render(await Page(props));
expect(redirect).toHaveBeenCalledWith("./");
});
test("renders correctly when integration is configured", async () => {
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true,
airtableIntegration: mockAirtableIntegration,
airtableArray: mockAirtableTables,
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined
);
});
test("renders correctly when integration exists but is not configured (no key)", async () => {
const integrationWithoutKey = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, key: undefined },
} as unknown as TIntegrationAirtable;
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
// Update assertion to match the actual call
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
airtableIntegration: integrationWithoutKey,
airtableArray: [], // Should be empty as getAirtableTables is not called
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined // Change second argument to undefined
);
});
test("renders correctly when integration is disabled (no client ID)", async () => {
mockAirtableClientId = undefined; // Simulate disabled integration
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
expect.objectContaining({
isEnabled: false, // Should be false
}),
undefined
);
});
});
@@ -0,0 +1,694 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
getSpreadsheetNameByIdAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
<div>
<label>{label}</label>
<select
data-testid="survey-dropdown"
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}>
<option value="">Select a survey</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
return "Please enter a valid Google Sheet URL.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
return "Sync responses with Google Sheets.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const getSpreadsheetNameByIdAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
.getSpreadsheetNameByIdAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
key: {
access_token: "mock_access_token",
expiry_date: Date.now() + 3600000,
refresh_token: "mock_refresh_token",
scope: "mock_scope",
token_type: "Bearer",
},
email: "test@example.com",
data: [], // Initially empty, will be populated in beforeEach
},
} as unknown as TIntegrationGoogleSheets;
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
spreadsheetId: "existing-sheet-id",
spreadsheetName: "Existing Sheet",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddIntegrationModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockGoogleSheetIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toBeInTheDocument();
// Use getByTestId for the dropdown
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={{
...mockGoogleSheetIntegration,
config: { ...mockGoogleSheetIntegration.config, data: [] },
}} // Start with empty data
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
googleSheetIntegration: expect.any(Object),
environmentId,
spreadsheetId: "new-sheet-id",
});
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "googleSheets",
config: expect.objectContaining({
key: mockGoogleSheetIntegration.config.key,
email: mockGoogleSheetIntegration.config.email,
data: expect.arrayContaining([
expect.objectContaining({
spreadsheetId: "new-sheet-id",
spreadsheetName: "Test Sheet Name",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error for invalid URL", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "invalid-url");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (URL should be empty)
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input check after re-render
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("");
});
});
@@ -0,0 +1,175 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock child components and functions
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
() => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
</div>
)),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization}>Connect</button>
</div>
)),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
() => ({
AddIntegrationModal: vi.fn(({ open }) =>
open ? <div data-testid="add-integration-modal">Modal</div> : null
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockWebAppUrl = "http://localhost:3000";
const mockLocale = "en-US";
const mockGoogleSheetIntegration = {
id: "test-integration-id",
type: "googleSheets",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
data: [],
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
describe("GoogleSheetWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
// No googleSheetIntegration provided initially
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
});
test("renders ConnectIntegration when integration exists but has no key", () => {
const integrationWithoutKey = {
...mockGoogleSheetIntegration,
config: { data: [], email: "test" },
} as unknown as TIntegrationGoogleSheets;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={integrationWithoutKey}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize when connect button is clicked", async () => {
const user = userEvent.setup();
// Mock window.location.replace
const originalLocation = window.location;
// @ts-expect-error
delete window.location;
window.location = { ...originalLocation, replace: vi.fn() } as any;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
// Need to wait for the promise returned by authorize to resolve
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
});
// Restore window.location
window.location = originalLocation as any;
});
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Modal is rendered but initially hidden
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
const user = userEvent.setup();
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
await user.click(openModalButton);
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./google";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith(
{ errorText },
"authorize: Could not fetch google sheet config"
);
});
});
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util";
describe("Google Sheets Util", () => {
describe("extractSpreadsheetIdFromUrl", () => {
test("should extract spreadsheet ID from a valid URL", () => {
const url =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId);
});
test("should throw an error for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL");
});
test("should throw an error for a URL without an ID", () => {
const urlWithoutId = "https://docs.google.com/spreadsheets/d/";
expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL");
});
});
describe("constructGoogleSheetsUrl", () => {
test("should construct a valid Google Sheets URL from a spreadsheet ID", () => {
const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
const expectedUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl);
});
});
describe("isValidGoogleSheetsUrl", () => {
test("should return true for a valid Google Sheets URL", () => {
const validUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
expect(isValidGoogleSheetsUrl(validUrl)).toBe(true);
});
test("should return false for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false);
});
test("should return true for a base Google Sheets URL", () => {
const baseUrl = "https://docs.google.com/spreadsheets/d/";
expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true);
});
});
});
@@ -0,0 +1,40 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock the GoBackButton component
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div>GoBackButton</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders the loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByText("GoBackButton")).toBeInTheDocument();
// Check for the disabled button text
expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button")
).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none");
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument();
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (count based on the loop)
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
// Calculate expected placeholders: 3 rows * 5 placeholders per row = 15
// Plus the button, header divs (4), and the main containers
// It's simpler to check if there are *any* pulse animations
expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true);
});
});
@@ -0,0 +1,228 @@
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
() => ({
GoogleSheetWrapper: vi.fn(
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
<div>
<span>Mocked GoogleSheetWrapper</span>
<span data-testid="isEnabled">{isEnabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{googleSheetIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockGoogleSheetClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get GOOGLE_SHEETS_CLIENT_ID() {
return mockGoogleSheetClientId;
},
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
data: [],
key: {
refresh_token: "refresh",
access_token: "access",
expiry_date: Date.now() + 3600000,
} as unknown as TIntegrationGoogleSheetsCredential,
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("GoogleSheetsIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(
screen.getByText("environments.integrations.google_sheets.google_sheets_integration")
).toBeInTheDocument();
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("isEnabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => {
mockGoogleSheetClientId = undefined;
const { default: PageWithMissingConstants } = (await import(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
)) as { default: typeof Page };
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
const PageComponent = await PageWithMissingConstants(mockProps);
render(PageComponent);
expect(screen.getByTestId("isEnabled")).toHaveTextContent("false");
});
test("handles case where no Google Sheet integration exists", async () => {
vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
});
});
@@ -0,0 +1,172 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
}));
vi.mock("@/lib/survey/utils");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just return the function
};
});
const environmentId = "test-environment-id";
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
];
const mockTransformedSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
{
id: "survey2",
name: "Survey 2",
status: "draft",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app",
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
];
describe("getSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should fetch and transform surveys successfully", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
if (!found) throw new Error("Survey not found in mock transformed data");
// Ensure the returned object matches the TSurvey structure precisely
return { ...found } as TSurvey;
});
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// Check if the inner cache function was called with the correct arguments
expect(cache).toHaveBeenCalledWith(
expect.any(Function), // The async function passed to cache
[`getSurveys-${environmentId}`], // The cache key
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
}
);
// Remove the assertion for reactCache being called within the test execution
// expect(reactCache).toHaveBeenCalled(); // Removed this line
});
test("should throw DatabaseError on Prisma known request error", async () => {
// No need to mock cache here again as beforeEach handles it
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2025",
clientVersion: "5.0.0",
meta: {}, // Added meta property
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
test("should throw original error on other errors", async () => {
// No need to mock cache here again as beforeEach handles it
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
});
@@ -0,0 +1,114 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getWebhookCountBySource } from "./webhook";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
},
},
}));
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
count: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return webhook count for a specific source", async () => {
const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[sourceZapier, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: sourceZapier,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
}
);
});
test("should return total webhook count when source is undefined", async () => {
const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: undefined,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-undefined`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
}
);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,606 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionTypes: () => [
{ id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" },
{ id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" },
{ id: TSurveyQuestionTypeEnum.Date, label: "Date" },
],
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, params?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database";
if (key === "environments.integrations.notion.sync_responses_with_a_notion_database")
return "Sync responses with a Notion database.";
if (key === "environments.integrations.notion.select_a_database") return "Select a database";
if (key === "common.select_survey") return "Select survey";
if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property")
return "Map Formbricks fields to Notion property";
if (key === "environments.integrations.notion.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.notion.please_select_a_database")
return "Please select a database.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.notion.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.notion.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
return "Please complete mapping fields.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.notion.notion_logo") return "Notion logo";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration")
return "Create at least one database.";
if (key === "environments.integrations.notion.duplicate_connection_warning")
return "Duplicate connection warning.";
if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to")
return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`;
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Date,
headline: { default: "Date Question?" },
required: true,
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const databases: TIntegrationNotionDatabase[] = [
{
id: "db1",
name: "Database 1 Title",
properties: {
prop1: { id: "p1", name: "Title Prop", type: "title" },
prop2: { id: "p2", name: "Text Prop", type: "rich_text" },
prop3: { id: "p3", name: "Number Prop", type: "number" },
prop4: { id: "p4", name: "Date Prop", type: "date" },
prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported
},
},
{
id: "db2",
name: "Database 2 Title",
properties: {
propA: { id: "pa", name: "Name", type: "title" },
propB: { id: "pb", name: "Email", type: "email" },
},
},
];
const mockNotionIntegration: TIntegrationNotion = {
id: "integration1",
type: "notion",
environmentId: environmentId,
config: {
key: {
access_token: "token",
bot_id: "bot",
workspace_name: "ws",
workspace_icon: "",
} as unknown as TIntegrationNotionCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = {
databaseId: databases[0].id,
databaseName: databases[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
column: { id: "p1", name: "Title Prop", type: "title" },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
column: { id: "p2", name: "Text Prop", type: "rich_text" },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Notion)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockNotionIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration}
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
expect(columnDropdowns).toHaveLength(2);
// Assert values for the first row
expect(questionDropdowns[0]).toHaveValue("q1");
expect(columnDropdowns[0]).toHaveValue("p1");
// Assert values for the second row
expect(questionDropdowns[1]).toHaveValue("var1");
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
});
test("selects database and survey, shows mapping", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument();
});
test("adds and removes mapping rows", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
const plusButton = screen.getByTestId("plus-icon");
await userEvent.click(plusButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration} // Contains initial data at index 0
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no database selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a database.");
});
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
});
test("shows validation error if no mapping defined", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
// Default mapping row is empty
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping.");
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const cancelButton = screen.getByText("Cancel");
await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset
});
});
@@ -0,0 +1,152 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { NotionWrapper } from "./NotionWrapper";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
// Mock child components
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(
(
{ handleAuthorization, isEnabled } // Reverted back to isEnabled
) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
{" "}
{/* Reverted back to isEnabled */}
Connect
</button>
</div>
)
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/notion-logo.svg", () => ({
default: "notion-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys: TSurvey[] = [];
const databases = [];
const locale = "en-US" as const;
const mockNotionIntegration: TIntegrationNotion = {
id: "int-notion-123",
type: "notion",
environmentId: environmentId,
config: {
key: { access_token: "test-token" } as TIntegrationNotionCredential,
data: [],
},
};
const baseProps = {
environment,
surveys,
databasesArray: databases, // Renamed databases to databasesArray to match component prop
webAppUrl,
locale,
};
describe("NotionWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration disabled when enabled is false", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={false} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => {
// Changed description slightly
const integrationWithoutKey = {
...mockNotionIntegration,
config: { data: [] },
} as unknown as TIntegrationNotion;
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={integrationWithoutKey} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://notion.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
});
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./notion";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config");
});
});
@@ -0,0 +1,50 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock child components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, className }: { children: React.ReactNode; className: string }) => (
<button className={className}>{children}</button>
),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div data-testid="go-back-button">Go Back</div>,
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Simple mock translation
}),
}));
describe("Notion Integration Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByTestId("go-back-button")).toBeInTheDocument();
// Check for the disabled button
const linkButton = screen.getByText("environments.integrations.notion.link_database");
expect(linkButton).toBeInTheDocument();
expect(linkButton.closest("button")).toHaveClass(
"pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"
);
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (skeleton loaders)
// There should be 3 rows * 5 pulse divs per row = 15 pulse divs
const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" });
expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered
});
});
@@ -0,0 +1,250 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
NotionWrapper: vi.fn(
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked NotionWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{notionIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockNotionClientId: string | undefined = "test-client-id";
let mockNotionClientSecret: string | undefined = "test-client-secret";
let mockNotionAuthUrl: string | undefined = "https://notion.com/auth";
let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect";
vi.mock("@/lib/constants", () => ({
get NOTION_OAUTH_CLIENT_ID() {
return mockNotionClientId;
},
get NOTION_OAUTH_CLIENT_SECRET() {
return mockNotionClientSecret;
},
get NOTION_AUTH_URL() {
return mockNotionAuthUrl;
},
get NOTION_REDIRECT_URI() {
return mockNotionRedirectUri;
},
WEBAPP_URL: "test-webapp-url",
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/notion/service", () => ({
getNotionDatabases: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockNotionIntegration = {
id: "integration1",
type: "notion",
config: {
data: [],
key: { bot_id: "bot-id-123" },
email: "test@example.com",
},
} as unknown as TIntegrationNotion;
const mockDatabases: TIntegrationNotionDatabase[] = [
{ id: "db1", name: "Database 1", properties: {} },
{ id: "db2", name: "Database 2", properties: {} },
];
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("NotionIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration);
vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
mockNotionClientId = "test-client-id";
mockNotionClientSecret = "test-client-secret";
mockNotionAuthUrl = "https://notion.com/auth";
mockNotionRedirectUri = "https://app.formbricks.com/redirect";
});
test("renders the page with NotionWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes enabled=false to NotionWrapper when constants are missing", async () => {
mockNotionClientId = undefined; // Simulate missing constant
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("enabled")).toHaveTextContent("false");
});
test("handles case where no Notion integration exists", async () => {
vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
test("handles case where integration exists but has no key (bot_id)", async () => {
const integrationWithoutKey = {
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, key: undefined },
} as unknown as TIntegrationNotion;
vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id);
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,243 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
import { getIntegrations } from "@/lib/integration/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegration } from "@formbricks/types/integration";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
getWebhookCountBySource: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/integration-card", () => ({
Card: ({ label, description, statusText, disabled }) => (
<div data-testid={`card-${label}`}>
<h1>{label}</h1>
<p>{description}</p>
<span>{statusText}</span>
{disabled && <span>Disabled</span>}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }) => <img alt={alt} />,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: true,
} as unknown as TEnvironment;
const mockIntegrations: TIntegration[] = [
{
id: "google-sheets-id",
type: "googleSheets",
environmentId: "test-env-id",
config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"],
},
{
id: "slack-id",
type: "slack",
environmentId: "test-env-id",
config: { data: [] } as unknown as TIntegration["config"],
},
];
const mockParams = { environmentId: "test-env-id" };
const mockProps = { params: mockParams };
describe("Integrations Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getWebhookCountBySource).mockResolvedValue(0);
vi.mocked(getIntegrations).mockResolvedValue([]);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
});
test("renders the page header and integration cards", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "zapier") return 1;
if (source === "user") return 2;
return 0;
});
vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.website_or_app_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status
expect(screen.getByTestId("card-Zapier")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument();
expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status
expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument();
expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status
expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheet_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status
expect(screen.getByTestId("card-Airtable")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.airtable_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status
expect(screen.getByTestId("card-Slack")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status
expect(screen.getByTestId("card-n8n")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status
expect(screen.getByTestId("card-Make.com")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status
expect(screen.getByTestId("card-Notion")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status
expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.activepieces_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status
});
test("renders disabled cards when isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
// JS SDK and Webhooks should not be disabled
expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled");
// Other cards should be disabled
expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled");
});
test("redirects when isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: true,
} as unknown as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
`/environments/${mockParams.environmentId}/settings/billing`
);
});
test("renders correct status text for single integration", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 1;
if (source === "make") return 1;
if (source === "activepieces") return 1;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration");
});
test("renders correct status text for multiple integrations", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 3;
if (source === "make") return 4;
if (source === "activepieces") return 5;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations");
});
test("renders not connected status when widgetSetupCompleted is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: { ...mockEnvironment, appSetupCompleted: false },
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected");
});
});
@@ -0,0 +1,750 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { AddChannelMappingModal } from "./AddChannelMappingModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, disabled }: any) => (
<div>
<label>{label}</label>
<select
data-testid={label.includes("channel") ? "channel-dropdown" : "survey-dropdown"}
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">Select...</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.slack.select_channel") return "Select channel";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.slack.please_select_a_channel")
return "Please select a channel.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.slack.dont_see_your_channel") return "Don't see your channel?";
if (key === "common.note") return "Note";
if (key === "environments.integrations.slack.already_connected_another_survey")
return "This channel is already connected to another survey.";
if (key === "environments.integrations.slack.create_at_least_one_channel_error")
return "Please create at least one channel in Slack first.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.slack.link_channel") return "Link Channel";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
vi.mock("lucide-react", () => ({
CircleHelpIcon: () => <div data-testid="circle-help-icon" />,
Check: () => <div data-testid="check-icon" />, // Add the Check icon mock
Loader2: () => <div data-testid="loader-icon" />, // Add the Loader2 icon mock
}));
// Mock dependencies
const createOrUpdateIntegrationActionMock = vi.mocked(createOrUpdateIntegrationAction);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const channels: TIntegrationItem[] = [
{ id: "channel1", name: "#general" },
{ id: "channel2", name: "#random" },
];
const mockSlackIntegration: TIntegrationSlack = {
id: "integration1",
type: "slack",
environmentId: environmentId,
config: {
key: {
access_token: "xoxb-test-token",
team_name: "Test Team",
team_id: "T123",
} as unknown as TIntegrationSlackCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationSlackConfigData & { index: number } = {
channelId: channels[0].id,
channelName: channels[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddChannelMappingModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockSlackIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Channel" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
expect(screen.getByText("Don't see your channel?")).toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={{ ...mockSlackIntegration, config: { ...mockSlackIntegration.config, data: [] } }} // Start with empty data
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[1].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "slack",
config: expect.objectContaining({
key: mockSlackIntegration.config.key,
data: expect.arrayContaining([
expect.objectContaining({
channelId: channels[1].id,
channelName: channels[1].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any });
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration} // Contains initial data at index 0
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no channel selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// No channel selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a channel.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
createOrUpdateIntegrationActionMock.mockRejectedValue(new Error(errorMessage));
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (channel should be unselected)
cleanup();
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("channel-dropdown")).toHaveValue("");
});
test("shows warning when selected channel is already connected (add mode)", async () => {
// Add an existing connection for channel1
const integrationWithExisting = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey-other",
surveyName: "Other Survey",
questionIds: ["q-other"],
questions: "All questions",
createdAt: new Date(),
} as TIntegrationSlackConfigData,
],
},
};
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationWithExisting}
channels={channels}
selectedIntegration={null} // Add mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
await userEvent.selectOptions(channelDropdown, "channel1");
expect(screen.getByText("This channel is already connected to another survey.")).toBeInTheDocument();
});
test("does not show warning when selected channel is the one being edited", async () => {
// Edit the existing connection for channel1
const integrationToEdit = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey1",
surveyName: "Survey 1",
questionIds: ["q1"],
questions: "Selected questions",
createdAt: new Date(),
index: 0,
} as TIntegrationSlackConfigData & { index: number },
],
},
};
const selectedIntegrationForEdit = integrationToEdit.config.data[0];
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationToEdit}
channels={channels}
selectedIntegration={selectedIntegrationForEdit} // Edit mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
// Channel is already selected via selectedIntegration prop
expect(channelDropdown).toHaveValue("channel1");
expect(
screen.queryByText("This channel is already connected to another survey.")
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,171 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "../actions";
import { authorize } from "../lib/slack";
import { SlackWrapper } from "./SlackWrapper";
// Mock child components and actions
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/actions", () => ({
getSlackChannelsAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal",
() => ({
AddChannelMappingModal: vi.fn(({ open }) => (open ? <div data-testid="add-modal">Add Modal</div> : null)),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
<button onClick={() => setIsConnected(false)}>Disconnect</button>
<button onClick={handleSlackAuthorization}>Reconnect</button>
</div>
)),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack", () => ({
authorize: vi.fn(),
}));
vi.mock("@/images/slacklogo.png", () => ({
default: "slack-logo-path",
}));
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(({ handleAuthorization, isEnabled }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
Connect
</button>
</div>
)),
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockWebAppUrl = "http://localhost:3000";
const mockLocale: TUserLocale = "en-US";
const mockSlackChannels: TIntegrationItem[] = [{ id: "C123", name: "general" }];
const mockSlackIntegration: TIntegrationSlack = {
id: "slack-int-1",
type: "slack",
environmentId: "test-env-id",
config: {
key: { access_token: "xoxb-valid-token" } as unknown as TIntegrationSlackCredential,
data: [],
},
};
const baseProps = {
environment: mockEnvironment,
surveys: mockSurveys,
webAppUrl: mockWebAppUrl,
locale: mockLocale,
};
describe("SlackWrapper", () => {
beforeEach(() => {
vi.mocked(getSlackChannelsAction).mockResolvedValue({ data: mockSlackChannels });
vi.mocked(authorize).mockResolvedValue("https://slack.com/auth");
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected (no integration)", () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
});
test("renders ConnectIntegration when not connected (integration without key)", () => {
const integrationWithoutKey = { ...mockSlackIntegration, config: { data: [], email: "test" } } as any;
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={integrationWithoutKey} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration disabled when isEnabled is false", () => {
render(<SlackWrapper {...baseProps} isEnabled={false} slackIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
});
});
test("renders ManageIntegration and AddChannelMappingModal (hidden) when connected", () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument(); // Modal is initially hidden
});
test("calls getSlackChannelsAction on mount", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
await waitFor(() => {
expect(getSlackChannelsAction).toHaveBeenCalledWith({ environmentId: mockEnvironment.id });
});
});
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
await userEvent.click(disconnectButton);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("opens AddChannelMappingModal when triggered from ManageIntegration", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument();
const openModalButton = screen.getByRole("button", { name: "Open Modal" });
await userEvent.click(openModalButton);
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("calls handleSlackAuthorization when reconnect button is clicked in ManageIntegration", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
const reconnectButton = screen.getByRole("button", { name: "Reconnect" });
await userEvent.click(reconnectButton);
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
});
});
});
@@ -0,0 +1,51 @@
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./slack";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
global.fetch = vi.fn();
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/slack`;
const expectedAuthUrl = "http://slack.com/auth";
test("should return authUrl on successful fetch", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: expectedAuthUrl } }),
} as Response);
const authUrl = await authorize(environmentId, apiHost);
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
});
expect(authUrl).toBe(expectedAuthUrl);
});
test("should throw error and log error on failed fetch", async () => {
const errorText = "Failed to fetch";
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
text: async () => errorText,
} as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config");
});
});
@@ -0,0 +1,222 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import Page from "@/app/(app)/environments/[environmentId]/integrations/slack/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper", () => ({
SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => (
<div data-testid="slack-wrapper">
Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys=
{surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale}
</div>
)),
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: true,
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SLACK_CLIENT_ID: "test-slack-client-id",
SLACK_CLIENT_SECRET: "test-slack-client-secret",
WEBAPP_URL: "http://test.formbricks.com",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back-button">Go Back: {url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1 data-testid="page-header">{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
// Mock data
const environmentId = "test-env-id";
const mockEnvironment = {
id: environmentId,
createdAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: environmentId,
status: "inProgress",
type: "link",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
languages: [],
styling: null,
segment: null,
resultShareKey: null,
displayPercentage: null,
closeOnDate: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockSlackIntegration = {
id: "slack-int-id",
type: "slack",
config: {
data: [],
key: "test-key" as unknown as TIntegrationSlackCredential,
},
} as unknown as TIntegrationSlack;
const mockLocale = "en-US";
const mockParams = { params: { environmentId } };
describe("SlackIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockSlackIntegration);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
test("renders correctly when user is not read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
const tree = await Page(mockParams);
render(tree);
expect(screen.getByTestId("page-header")).toHaveTextContent(
"environments.integrations.slack.slack_integration"
);
expect(screen.getByTestId("go-back-button")).toHaveTextContent(
`Go Back: http://test.formbricks.com/environments/${environmentId}/integrations`
);
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
// Check props passed to SlackWrapper
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
{
isEnabled: true, // Since SLACK_CLIENT_ID and SLACK_CLIENT_SECRET are mocked
environment: mockEnvironment,
surveys: mockSurveys,
slackIntegration: mockSlackIntegration,
webAppUrl: "http://test.formbricks.com",
locale: mockLocale,
},
undefined
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("redirects when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: true,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
// Need to actually call the component function to trigger the redirect logic
await Page(mockParams);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
expect(vi.mocked(SlackWrapper)).not.toHaveBeenCalled();
});
test("renders correctly when Slack integration is not configured", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
vi.mocked(getIntegrationByType).mockResolvedValue(null); // Simulate no integration found
const tree = await Page(mockParams);
render(tree);
expect(screen.getByTestId("page-header")).toHaveTextContent(
"environments.integrations.slack.slack_integration"
);
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
// Check props passed to SlackWrapper when integration is null
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
{
isEnabled: true,
environment: mockEnvironment,
surveys: mockSurveys,
slackIntegration: null, // Expecting null here
webAppUrl: "http://test.formbricks.com",
locale: mockLocale,
},
undefined
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import WebhooksPage from "./page";
vi.mock("@/modules/integrations/webhooks/page", () => ({
WebhooksPage: vi.fn(() => <div>WebhooksPageMock</div>),
}));
describe("WebhooksIntegrationPage", () => {
test("renders WebhooksPage component", () => {
render(<WebhooksPage params={{ environmentId: "test-env-id" }} />);
expect(WebhooksPage).toHaveBeenCalled();
});
});
@@ -9,8 +9,8 @@ import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
@@ -18,28 +18,27 @@ export const POST = async (req: NextRequest): Promise<Response> => {
}
const accessType = "public"; // public files are accessible by anyone
const headersList = await headers();
const fileType = headersList.get("X-File-Type");
const encodedFileName = headersList.get("X-File-Name");
const environmentId = headersList.get("X-Environment-ID");
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const signedSignature = headersList.get("X-Signature");
const signedUuid = headersList.get("X-UUID");
const signedTimestamp = headersList.get("X-Timestamp");
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("fileType is required");
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
@@ -88,8 +87,9 @@ export const POST = async (req: NextRequest): Promise<Response> => {
return responses.unauthorizedResponse();
}
const formData = await req.formData();
const file = formData.get("file") as unknown as File;
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
@@ -105,6 +105,7 @@ export const POST = async (req: NextRequest): Promise<Response> => {
message: "File uploaded successfully",
});
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
}
+266
View File
@@ -0,0 +1,266 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as fileUploadModule from "./fileUpload";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockAtoB = vi.fn();
global.atob = mockAtoB;
// Mock FileReader
const mockFileReader = {
readAsDataURL: vi.fn(),
result: "data:image/jpeg;base64,test",
onload: null as any,
onerror: null as any,
};
// Mock File object
const createMockFile = (name: string, type: string, size: number) => {
const file = new File([], name, { type });
Object.defineProperty(file, "size", {
value: size,
writable: false,
});
return file;
};
describe("fileUpload", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock FileReader
global.FileReader = vi.fn(() => mockFileReader) as any;
global.atob = (base64) => Buffer.from(base64, "base64").toString("binary");
});
afterEach(() => {
vi.clearAllMocks();
});
test("should return error when no file is provided", async () => {
const result = await fileUploadModule.handleFileUpload(null as any, "test-env");
expect(result.error).toBe(fileUploadModule.FileUploadError.NO_FILE);
expect(result.url).toBe("");
});
test("should return error when file is not an image", async () => {
const file = createMockFile("test.pdf", "application/pdf", 1000);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Please upload an image file.");
expect(result.url).toBe("");
});
test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB
// Mock arrayBuffer to return >10MB buffer
file.arrayBuffer = vi.fn().mockResolvedValueOnce(new ArrayBuffer(11 * 1024 * 1024)); // 11MB
const result = await fileUploadModule.handleFileUpload(file, "env-oversize-buffer");
expect(result.error).toBe(fileUploadModule.FileUploadError.FILE_SIZE_EXCEEDED);
expect(result.url).toBe("");
});
test("should handle API error when getting signed URL", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock failed API response
mockFetch.mockResolvedValueOnce({
ok: false,
});
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should handle successful file upload with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle successful file upload without presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
signingData: {
signature: "test-signature",
timestamp: 1234567890,
uuid: "test-uuid",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
global.atob = vi.fn(() => {
throw new Error("Failed to decode base64 string");
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should handle upload error", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
// Mock failed upload response
mockFetch.mockResolvedValueOnce({
ok: false,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should catch unexpected errors and return UPLOAD_FAILED", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Force arrayBuffer() to throw
file.arrayBuffer = vi.fn().mockImplementation(() => {
throw new Error("Unexpected crash in arrayBuffer");
});
const result = await fileUploadModule.handleFileUpload(file, "env-crash");
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
expect(result.url).toBe("");
});
});
describe("fileUploadModule.toBase64", () => {
test("resolves with base64 string when FileReader succeeds", async () => {
const dummyFile = new File(["hello"], "hello.txt", { type: "text/plain" });
// Mock FileReader
const mockReadAsDataURL = vi.fn();
const mockFileReaderInstance = {
readAsDataURL: mockReadAsDataURL,
onload: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
onerror: null,
result: "data:text/plain;base64,aGVsbG8=",
};
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
const promise = fileUploadModule.toBase64(dummyFile);
// Trigger the onload manually
mockFileReaderInstance.onload?.call(mockFileReaderInstance as unknown as FileReader, new Error("load"));
const result = await promise;
expect(result).toBe("data:text/plain;base64,aGVsbG8=");
});
test("rejects when FileReader errors", async () => {
const dummyFile = new File(["oops"], "oops.txt", { type: "text/plain" });
const mockReadAsDataURL = vi.fn();
const mockFileReaderInstance = {
readAsDataURL: mockReadAsDataURL,
onload: null,
onerror: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
result: null,
};
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
const promise = fileUploadModule.toBase64(dummyFile);
// Simulate error
mockFileReaderInstance.onerror?.call(mockFileReaderInstance as unknown as FileReader, new Error("error"));
await expect(promise).rejects.toThrow();
});
});
+128 -72
View File
@@ -1,90 +1,146 @@
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
}
export const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
export const handleFileUpload = async (
file: File,
environmentId: string
environmentId: string,
allowedFileExtensions?: string[]
): Promise<{
error?: string;
error?: FileUploadError;
url: string;
}> => {
if (!file) return { error: "No file provided", url: "" };
try {
if (!(file instanceof File)) {
return {
error: FileUploadError.NO_FILE,
url: "",
};
}
if (!file.type.startsWith("image/")) {
return { error: "Please upload an image file.", url: "" };
}
if (!file.type.startsWith("image/")) {
return { error: FileUploadError.INVALID_FILE_TYPE, url: "" };
}
if (file.size > 10 * 1024 * 1024) {
return {
error: "File size must be less than 10 MB.",
url: "",
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",
};
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
environmentId,
};
}
const payload = {
fileName: file.name,
fileType: file.type,
environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
// throw new Error(`Upload failed with status: ${response.status}`);
return {
error: "Upload failed. Please try again.",
url: "",
};
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Environment-ID": environmentId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
}
// Add the actual file to be uploaded
formData.append("file", file);
if (!response.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
environmentId,
signature,
timestamp: String(timestamp),
uuid,
};
}
const fileBase64 = (await toBase64(file)) as string;
const formData: Record<string, string> = {};
const formDataForS3 = new FormData();
if (presignedFields) {
Object.entries(presignedFields as Record<string, string>).forEach(([key, value]) => {
formDataForS3.append(key, value);
});
try {
const binaryString = atob(fileBase64.split(",")[1]);
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
const blob = new Blob([uint8Array], { type: file.type });
formDataForS3.append("file", blob);
} catch (err) {
console.error(err);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
}
formData.fileBase64String = fileBase64;
const uploadResponse = await fetch(signedUrl, {
method: "POST",
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
if (!uploadResponse.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
if (!uploadResponse.ok) {
return {
error: "Upload failed. Please try again.",
url: fileUrl,
};
} catch (error) {
console.error("Error in uploading file: ", error);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
return {
url: fileUrl,
};
};
+1 -11
View File
@@ -177,17 +177,6 @@ export const authOptions: NextAuthOptions = {
// Conditionally add enterprise SSO providers
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
cookies: {
sessionToken: {
name: "next-auth.session-token",
options: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
},
session: {
maxAge: 3600,
},
@@ -230,6 +219,7 @@ export const authOptions: NextAuthOptions = {
}
if (ENTERPRISE_LICENSE_KEY) {
const result = await handleSsoCallback({ user, account, callbackUrl });
if (result) {
await updateUserLastLoginAt(user.email);
}
@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
@@ -1,9 +1,9 @@
import { handleFileUpload } from "@/app/lib/fileUpload";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -18,8 +18,8 @@ vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
updateOrganizationEmailLogoUrlAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/file-input/lib/utils", () => ({
uploadFile: vi.fn(),
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const defaultProps = {
@@ -82,8 +82,7 @@ describe("EmailCustomizationSettings", () => {
});
test("calls updateOrganizationEmailLogoUrlAction after uploading and clicking save", async () => {
vi.mocked(uploadFile).mockResolvedValueOnce({
uploaded: true,
vi.mocked(handleFileUpload).mockResolvedValueOnce({
url: "https://example.com/new-uploaded-logo.png",
});
vi.mocked(updateOrganizationEmailLogoUrlAction).mockResolvedValue({
@@ -104,7 +103,7 @@ describe("EmailCustomizationSettings", () => {
await user.click(saveButton[0]);
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
expect(uploadFile).toHaveBeenCalledWith(testFile, ["jpeg", "png", "jpg", "webp"], "env-123");
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",
logoUrl: "https://example.com/new-uploaded-logo.png",
@@ -1,6 +1,7 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
@@ -11,7 +12,6 @@ import {
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
@@ -120,7 +120,13 @@ export const EmailCustomizationSettings = ({
const handleSave = async () => {
if (!logoFile) return;
setIsSaving(true);
const { url } = await uploadFile(logoFile, allowedFileExtensions, environmentId);
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
if (error) {
toast.error(error);
setIsSaving(false);
return;
}
const updateLogoResponse = await updateOrganizationEmailLogoUrlAction({
organizationId: organization.id,
@@ -205,7 +211,7 @@ export const EmailCustomizationSettings = ({
data-testid="replace-logo-button"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={isReadOnly}>
disabled={isReadOnly || isSaving}>
<RepeatIcon className="h-4 w-4" />
{t("environments.settings.general.replace_logo")}
</Button>
@@ -213,7 +219,7 @@ export const EmailCustomizationSettings = ({
data-testid="remove-logo-button"
onClick={removeLogo}
variant="outline"
disabled={isReadOnly}>
disabled={isReadOnly || isSaving}>
<Trash2Icon className="h-4 w-4" />
{t("environments.settings.general.remove_logo")}
</Button>
@@ -241,7 +247,7 @@ export const EmailCustomizationSettings = ({
<Button
data-testid="send-test-email-button"
variant="secondary"
disabled={isReadOnly}
disabled={isReadOnly || isSaving}
onClick={sendTestEmail}>
{t("common.send_test_email")}
</Button>
@@ -88,7 +88,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
<source src={`${key}`} type="video/mp4" />
</video>
<input
className="absolute top-2 right-2 h-4 w-4 rounded-sm bg-white"
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white"
type="checkbox"
checked={animation === value}
onChange={() => handleBg(value)}
@@ -67,7 +67,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -20,7 +20,6 @@ interface PictureSelectionFormProps {
question: TSurveyPictureSelectionQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyPictureSelectionQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -196,7 +196,7 @@ export const QuestionCard = ({
)}>
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
<button className="opacity-0 group-hover:opacity-100 hover:cursor-move">
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
</button>
</div>
@@ -377,7 +377,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -91,7 +91,7 @@ export const SurveyPlacementCard = ({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -41,7 +41,7 @@ export const SurveyVariablesCard = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<div className="flex w-full justify-center">
<FileDigitIcon className="h-4 w-4" />
@@ -75,7 +75,7 @@ export const SurveyVariablesCard = ({
/>
))
) : (
<p className="mt-2 text-sm text-slate-500 italic">
<p className="mt-2 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
@@ -75,7 +75,7 @@ const FollowUpActionMultiEmailInput = ({
<span className="text-slate-900">{email}</span>
<button
onClick={() => removeEmail(index)}
className="px-1 text-lg leading-none font-medium text-slate-500">
className="px-1 text-lg font-medium leading-none text-slate-500">
×
</button>
</div>
@@ -98,11 +98,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
field.onChange([...field.value, environment.id]);
}
}}
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium text-slate-900 capitalize">
<p className="text-sm font-medium capitalize text-slate-900">
{environment.type}
</p>
</Label>
@@ -121,8 +121,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
);
})}
</div>
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pr-4 pb-4">
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>
@@ -32,7 +32,7 @@ vi.mock("@/modules/ui/components/checkbox", () => ({
id={id}
data-testid={id}
name={props.name}
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
onChange={() => {
// Call onCheckedChange with true to simulate checkbox selection
onCheckedChange(true);
@@ -1,5 +1,6 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
@@ -11,7 +12,7 @@ import toast from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { Uploader } from "./components/uploader";
import { VideoSettings } from "./components/video-settings";
import { getAllowedFiles, uploadFile } from "./lib/utils";
import { getAllowedFiles } from "./lib/utils";
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
const isImage = (name: string) => {
@@ -21,7 +22,7 @@ const isImage = (name: string) => {
interface FileInputProps {
id: string;
allowedFileExtensions: TAllowedFileExtension[];
environmentId: string | undefined;
environmentId: string;
onFileUpload: (uploadedUrl: string[] | undefined, fileType: "image" | "video") => void;
fileUrl?: string | string[];
videoUrl?: string;
@@ -78,14 +79,11 @@ export const FileInput = ({
allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false }))
);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
const uploadedFiles = await Promise.all(
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else {
@@ -95,8 +93,8 @@ export const FileInput = ({
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(encodeURI(file.value.url));
if (file.url) {
uploadedUrls.push(encodeURI(file.url));
}
});
@@ -147,14 +145,11 @@ export const FileInput = ({
...allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false })),
]);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
const uploadedFiles = await Promise.all(
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else {
@@ -164,8 +159,8 @@ export const FileInput = ({
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(encodeURI(file.value.url));
if (file.url) {
uploadedUrls.push(encodeURI(file.url));
}
});
@@ -0,0 +1,101 @@
import { toast } from "react-hot-toast";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { convertHeicToJpegAction } from "./actions";
import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils";
// Mock FileReader
class MockFileReader {
onload: (() => void) | null = null;
onerror: ((error: any) => void) | null = null;
result: string | null = null;
readAsDataURL() {
// Simulate asynchronous read
setTimeout(() => {
this.result = "data:text/plain;base64,dGVzdA=="; // base64 for "test"
if (this.onload) {
this.onload();
}
}, 0);
}
}
// Mock global FileReader
global.FileReader = MockFileReader as any;
// Mock dependencies
vi.mock("react-hot-toast", () => ({
toast: {
error: vi.fn(),
},
}));
vi.mock("./actions", () => ({
convertHeicToJpegAction: vi.fn(),
}));
describe("File Input Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getAllowedFiles", () => {
test("should filter out files with unsupported extensions", async () => {
const files = [
new File(["test"], "test.txt", { type: "text/plain" }),
new File(["test"], "test.doc", { type: "application/msword" }),
];
const result = await getAllowedFiles(files, ["txt"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("test.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Unsupported file types: test.doc"));
});
test("should filter out files exceeding size limit", async () => {
const files = [
new File(["x".repeat(6 * 1024 * 1024)], "large.txt", { type: "text/plain" }),
new File(["test"], "small.txt", { type: "text/plain" }),
];
const result = await getAllowedFiles(files, ["txt"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("small.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Files exceeding size limit (5 MB)"));
});
test("should convert HEIC files to JPEG", async () => {
const heicFile = new File(["test"], "test.heic", { type: "image/heic" });
const mockConvertedFile = new File(["converted"], "test.jpg", { type: "image/jpeg" });
vi.mocked(convertHeicToJpegAction).mockResolvedValue({
data: mockConvertedFile,
});
const result = await getAllowedFiles([heicFile], ["heic", "jpg"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("test.jpg");
expect(result[0].type).toBe("image/jpeg");
});
});
describe("checkForYoutubePrivacyMode", () => {
test("should return true for youtube-nocookie.com URLs", () => {
const url = "https://www.youtube-nocookie.com/watch?v=test";
expect(checkForYoutubePrivacyMode(url)).toBe(true);
});
test("should return false for regular youtube.com URLs", () => {
const url = "https://www.youtube.com/watch?v=test";
expect(checkForYoutubePrivacyMode(url)).toBe(false);
});
test("should return false for invalid URLs", () => {
const url = "not-a-url";
expect(checkForYoutubePrivacyMode(url)).toBe(false);
});
});
});
@@ -4,96 +4,6 @@ import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { convertHeicToJpegAction } from "./actions";
export const uploadFile = async (
file: File | Blob,
allowedFileExtensions: string[] | undefined,
environmentId: string | undefined
) => {
try {
if (!(file instanceof Blob) || !(file instanceof File)) {
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
}
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
const err = new Error("File size is greater than 10MB");
err.name = "FileTooLargeError";
throw err;
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions: allowedFileExtensions,
environmentId: environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Environment-ID": environmentId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
});
}
formData.append("file", file);
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status: ${uploadResponse.status}`);
}
return {
uploaded: true,
url: fileUrl,
};
} catch (error) {
throw error;
}
};
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {
if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
return true;
@@ -169,6 +79,7 @@ export const checkForYoutubePrivacyMode = (url: string): boolean => {
const parsedUrl = new URL(url);
return parsedUrl.host === "www.youtube-nocookie.com";
} catch (e) {
console.error("Invalid URL", e);
return false;
}
};
+35 -148
View File
@@ -15,157 +15,44 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: [
"modules/api/v2/**/*.ts",
"modules/api/v2/**/*.tsx",
"modules/auth/lib/**/*.ts",
"modules/auth/signup/components/signup-form.tsx",
"modules/auth/signup/page.tsx",
"modules/signup/lib/**/*.ts",
"modules/auth/signup/lib/**/*.ts",
"modules/auth/signup/**/*.tsx",
"modules/ee/whitelabel/email-customization/components/*.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/email/components/email-template.tsx",
"modules/email/emails/survey/follow-up.tsx",
"modules/email/emails/lib/*.tsx",
"modules/environments/lib/**/*.ts",
"modules/ui/components/post-hog-client/*.tsx",
"modules/ee/role-management/components/*.tsx",
"modules/ee/role-management/actions.ts",
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
"modules/ui/components/alert/*.tsx",
"modules/ui/components/environmentId-base-layout/*.tsx",
"modules/ui/components/survey/recaptcha.ts",
"modules/ui/components/progress-bar/index.tsx",
"app/(app)/environments/**/layout.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"app/(app)/environments/**/components/PosthogIdentify.tsx",
"app/(app)/(onboarding)/organizations/**/layout.tsx",
"app/(app)/(survey-editor)/environments/**/layout.tsx",
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/layout.tsx",
"app/api/v2/client/**/responses/lib/utils.ts",
"app/intercom/*.tsx",
"app/sentry/*.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/NPSSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/FileUploadSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/AddressSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/CalSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ContactInfoSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/CTASummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/DateQuestionSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryDropOffs.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryPage.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RankingSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/OpenTextSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/HiddenFieldsSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/QuestionSummaryHeader.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx",
"app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/google-sheets/components/ManageIntegration.tsx",
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/ee/sso/lib/organization.ts",
"app/lib/**/*.ts",
"modules/ee/license-check/lib/utils.ts",
"modules/ee/role-management/*.ts",
"modules/organization/settings/teams/actions.ts",
"modules/organization/settings/api-keys/lib/**/*.ts",
"app/api/**/*.ts",
"modules/api/v2/management/auth/*.ts",
"modules/organization/settings/api-keys/components/*.tsx",
"modules/survey/hooks/*.tsx",
"modules/survey/components/question-form-input/index.tsx",
"modules/survey/components/template-list/components/template-tags.tsx",
"modules/survey/lib/client-utils.ts",
"modules/survey/components/edit-public-survey-alert-dialog/index.tsx",
"modules/survey/list/lib/project.ts",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
"modules/auth/signup/**/*.ts",
"modules/survey/follow-ups/components/follow-up-item.tsx",
"modules/ee/contacts/segments/*",
"modules/survey/editor/lib/utils.tsx",
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
"modules/ee/sso/components/**/*.tsx",
"modules/ee/sso/lib/team.ts",
"app/global-error.tsx",
"app/error.tsx",
"modules/survey/lib/permission.ts",
"modules/account/**/*.tsx",
"modules/account/**/*.ts",
"modules/analysis/**/*.tsx",
"modules/analysis/**/*.ts",
"app/lib/survey-builder.ts",
"lib/utils/billing.ts",
"modules/survey/list/components/copy-survey-form.tsx",
"lib/crypto.ts",
"lib/surveyLogic/utils.ts",
"lib/utils/billing.ts",
"modules/ui/components/card/index.tsx",
"modules/survey/editor/components/*.tsx",
"lib/fileValidation.ts",
"modules/survey/editor/components/add-action-modal.tsx",
"modules/survey/editor/components/add-ending-card-button.tsx",
"modules/survey/editor/components/add-question-button.tsx",
"modules/survey/editor/components/advanced-settings.tsx",
"modules/survey/editor/components/color-survey-bg.tsx",
"modules/survey/editor/components/date-question-form.tsx",
"modules/survey/editor/components/file-upload-question-form.tsx",
"modules/survey/editor/components/how-to-send-card.tsx",
"modules/survey/editor/components/image-survey-bg.tsx",
"modules/ee/teams/**/*.ts",
"modules/ee/teams/**/*.tsx",
"app/(app)/environments/**/*.tsx",
"app/(app)/environments/**/*.ts",
],
include: ["app/**/*.{ts,tsx}", "modules/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"],
exclude: [
"**/.next/**",
"**/*.spec.*", // Excludes .spec files from coverage
"**/*.test.*", // Exclude .test files from coverage
"**/constants.ts", // Exclude constants files
"**/route.ts", // Exclude route files
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"**/types/**", // Exclude types
"**/types.ts", // Exclude types
"**/actions.ts",
"**/action.ts",
"**/*.mock.*",
"**/*.json",
"**/*.test.*",
"**/*.mdx",
"**/*.config.mts",
"**/*.config.ts",
"**/stories.*",
"**/mocks/**",
"**/__mocks__/**",
"**/instrumentation.ts",
"**/playwright/**",
"**/Dockerfile",
"**/*.config.cjs",
"**/*.css",
"**/templates.ts",
"**/.next/**", // Next.js build output
"**/*.spec.*", // Test files
"**/*.test.*", // Test files
"**/*.mock.*", // Mock files
"**/mocks/**", // Mock directories
"**/__mocks__/**", // Jest-style mock directories
"**/constants.ts", // Constants files
"**/route.ts", // Next.js API routes
"**/openapi.ts", // OpenAPI spec files
"**/openapi-document.ts", // OpenAPI-related document files
"**/types/**", // Type definition folders
"modules/**/types/**", // Specific type folders within modules
"**/types.ts", // Files named 'types.ts'
"**/actions.ts", // Server actions (plural)
"**/action.ts", // Server actions (singular)
"**/stories.*", // Storybook files (e.g., .stories.tsx)
"**/*.config.{js,ts,mjs,mts,cjs}", // All configuration files
"**/middleware.ts", // Next.js middleware
"**/instrumentation.ts", // Next.js instrumentation files
"**/instrumentation-node.ts", // Next.js Node.js instrumentation files
"**/vitestSetup.ts", // Vitest setup files
"**/*.json", // JSON files
"**/*.mdx", // MDX files
"**/playwright/**", // Playwright E2E test files
"**/Dockerfile", // Dockerfiles
"**/*.css", // CSS files
"**/templates.ts", // Project-specific template files
"scripts/**", // Utility scripts
"apps/web/modules/ui/components/icons/*",
"vitestSetup.ts", // Exclude Vitest setup file
"tailwind.config.js", // Exclude Tailwind CSS config file
"postcss.config.js", // Exclude PostCSS config file
"next.config.mjs", // Exclude Next.js config file
"scripts/**", // Exclude scripts folder (development scripts)
"**/cache.ts", // Exclude cache files
"packages/surveys/src/components/general/smileys.tsx",
"apps/web/modules/auth/lib/mock-data.ts", // Exclude mock data files
"apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx",
"**/*.mjs",
],
},
},
plugins: [tsconfigPaths(), react() as PluginOption],
});
});
@@ -12,7 +12,7 @@ releases:
- values.yaml.gotmpl
set:
- name: deployment.image.tag
value: v{{ requiredEnv "VERSION" }}
value: {{ requiredEnv "VERSION" }}
- name: deployment.image.repository
value: {{ requiredEnv "REPOSITORY" }}
labels:
@@ -26,7 +26,7 @@ releases:
createNamespace: true
set:
- name: deployment.image.tag
value: v{{ requiredEnv "VERSION" }}
value: {{ requiredEnv "VERSION" }}
- name: deployment.image.repository
value: {{ requiredEnv "REPOSITORY" }}
labels:
@@ -145,7 +145,7 @@ ingress:
paths:
- path: /
pathType: Prefix
serviceName: formbricks
serviceName: formbricks-stage
ingressClassName: alb
## RBAC
+2 -2
View File
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts