mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
44 Commits
v3.9.2
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fba692626 | ||
|
|
3ace91cdd5 | ||
|
|
4ba7bf5b3c | ||
|
|
bd1402a58b | ||
|
|
c2af0c3fb6 | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 | ||
|
|
53ef756723 | ||
|
|
0f0b743a10 | ||
|
|
3f7dafb65c | ||
|
|
9df791b5ff | ||
|
|
dea40d9757 | ||
|
|
dd12a589d6 | ||
|
|
af6e5ba31e | ||
|
|
2b57b2080b | ||
|
|
154c85a0f7 | ||
|
|
3f465d4594 | ||
|
|
94e883f4c3 | ||
|
|
38622101f1 | ||
|
|
0eb64c0084 | ||
|
|
409f5b1791 | ||
|
|
14398a9c4f | ||
|
|
d1cdf6e216 | ||
|
|
65da25a626 | ||
|
|
ce8b019e93 | ||
|
|
67d7fe016d | ||
|
|
47583b5a32 | ||
|
|
03c9a6aaae | ||
|
|
4dcf9b093b | ||
|
|
5ba5ebf63d | ||
|
|
115bea2792 | ||
|
|
b0495a8a42 | ||
|
|
faabd371f5 | ||
|
|
f0be6de0b3 | ||
|
|
b338c6d28d | ||
|
|
07e9a7c007 | ||
|
|
928bb3f8bc |
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
@@ -191,8 +190,7 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
@@ -200,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
||||
@@ -28,4 +28,5 @@ afterEach(() => {
|
||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
- You don't need to mock @tolgee/react
|
||||
- You don't need to mock @tolgee/react
|
||||
- Use "import "@testing-library/jest-dom/vitest";"
|
||||
22
.github/workflows/e2e.yml
vendored
22
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -48,15 +50,17 @@ jobs:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: allow
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
@@ -75,7 +79,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -89,8 +93,18 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
if [ -z "$LICENSE_KEY" ]; then
|
||||
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
|
||||
exit 1
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
|
||||
|
||||
// Mocks before import
|
||||
const pushMock = vi.fn();
|
||||
const refreshMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
|
||||
vi.mock("./OnboardingSetupInstructions", () => ({
|
||||
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConnectWithFormbricks", () => {
|
||||
const environment = { id: "env1" } as any;
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking finish button navigates to surveys", async () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
|
||||
await userEvent.click(button);
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
|
||||
});
|
||||
|
||||
test("refresh is called on visibilitychange to visible", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if session is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user lacks access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("User is not authorized to access this environment");
|
||||
});
|
||||
|
||||
test("renders children if user has access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div data-testid="child">Test Content</div>,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { XMTemplateList } from "./XMTemplateList";
|
||||
|
||||
// Prepare push mock and module mocks before importing component
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
||||
getXMTemplates: (t: any) => [
|
||||
{ id: 1, name: "tmpl1" },
|
||||
{ id: 2, name: "tmpl2" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
||||
}));
|
||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div>
|
||||
{options.map((opt, idx) => (
|
||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset mocks between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("XMTemplateList component", () => {
|
||||
const project = { id: "proj1" } as any;
|
||||
const user = { id: "user1" } as any;
|
||||
const environmentId = "env1";
|
||||
|
||||
test("creates survey and navigates on success", async () => {
|
||||
// Mock successful survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option0 = screen.getByTestId("option-0");
|
||||
await userEvent.click(option0);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
// Mock failed survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option1 = screen.getByTestId("option-1");
|
||||
await userEvent.click(option1);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#FFFFFF" },
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
config: {
|
||||
channel: "link" as const,
|
||||
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "e1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you for completing the survey!" },
|
||||
},
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("xm-templates", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
});
|
||||
expect(result.endings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe("templates.nps_survey_name");
|
||||
expect(result[1].name).toBe("templates.star_rating_survey_name");
|
||||
expect(result[2].name).toBe("templates.csat_survey_name");
|
||||
expect(result[3].name).toBe("templates.cess_survey_name");
|
||||
expect(result[4].name).toBe("templates.smileys_survey_name");
|
||||
expect(result[5].name).toBe("templates.enps_survey_name");
|
||||
});
|
||||
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFnType;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
// Dynamically import the mocked logger
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to load XM templates, returning empty array"
|
||||
);
|
||||
});
|
||||
});
|
||||
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getTeamsByOrganizationId } from "./onboarding";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/team", () => ({
|
||||
teamCache: {
|
||||
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getTeamsByOrganizationId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
expect(result).toEqual([
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unknown error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
||||
}));
|
||||
|
||||
// Ensure mocks are reset between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Profile avatar
|
||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
||||
// CreateOrganizationModal should be closed initially
|
||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
if (trigger) await userEvent.click(trigger);
|
||||
|
||||
// Click logout menu item
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import LandingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("next-auth");
|
||||
vi.mock("next/navigation");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LandingLayout", () => {
|
||||
test("redirects to login if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns notFound if no membership is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects to production environment if available", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([
|
||||
{
|
||||
id: "proj-123",
|
||||
organizationId: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
name: "Project 1",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
} as any,
|
||||
]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([
|
||||
{
|
||||
id: "env-123",
|
||||
type: "production",
|
||||
projectId: "proj-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
||||
});
|
||||
|
||||
test("renders children if no projects or production environment exist", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
const result = await LandingLayout(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
<>
|
||||
<div>Child Content</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("returns notFound if user does not exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
||||
|
||||
const result = await Page({ params });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("renders header, options, and close button when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header title and subtitle
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.channel.channel_select_title"
|
||||
);
|
||||
expect(
|
||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Options container with correct titles
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
||||
"organizations.projects.new.channel.in_product_surveys"
|
||||
);
|
||||
|
||||
// Close button link rendered when projects >=1
|
||||
const closeLink = screen.getByRole("link");
|
||||
expect(closeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("does not render close button when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
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,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: any) => (
|
||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Mode Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("renders header and options without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.mode.what_are_you_here_for"
|
||||
);
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
|
||||
// Mocks before imports
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/modules/ui/components/color-picker", () => ({
|
||||
ColorPicker: ({ color, onChange }: any) => (
|
||||
<button data-testid="color-picker" onClick={() => onChange("#000")}>
|
||||
{color}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, placeholder }: any) => (
|
||||
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/multi-select", () => ({
|
||||
MultiSelect: ({ value, options, onChange }: any) => (
|
||||
<select
|
||||
data-testid="multi-select"
|
||||
multiple
|
||||
value={value}
|
||||
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
|
||||
{options.map((o: any) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/survey", () => ({
|
||||
SurveyInline: () => <div data-testid="survey-inline" />,
|
||||
}));
|
||||
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
|
||||
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
|
||||
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
|
||||
}));
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ProjectSettings component", () => {
|
||||
const baseProps = {
|
||||
organizationId: "org1",
|
||||
projectMode: "cx",
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
canDoRoleManagement: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
const fillAndSubmit = async () => {
|
||||
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "TestProject");
|
||||
const nextButton = screen.getByRole("button", { name: "common.next" });
|
||||
await userEvent.click(nextButton);
|
||||
};
|
||||
|
||||
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env123", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(createProjectAction).toHaveBeenCalledWith({
|
||||
organizationId: "org1",
|
||||
data: expect.objectContaining({ teamIds: [] }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
|
||||
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
|
||||
});
|
||||
|
||||
test("successful createProject for app channel navigates to connect", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env456", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
|
||||
});
|
||||
|
||||
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env789", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
|
||||
});
|
||||
|
||||
test("shows error toast on createProject error response", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({ error: "err" });
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
|
||||
test("shows error toast on exception", async () => {
|
||||
(createProjectAction as any).mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: any) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
||||
() => ({
|
||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectSettingsPage", () => {
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
||||
|
||||
test("redirects to login when no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params, searchParams });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws when teams not found", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
|
||||
test("renders header, settings and close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.settings.project_settings_title"
|
||||
);
|
||||
// ProjectSettings stub receives mode prop
|
||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
||||
// Close link for existing projects
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("renders without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{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={"/"}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings } from "lucide-react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
||||
|
||||
describe("OnboardingOptionsContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders options with links", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Test Option",
|
||||
description: "Test Description",
|
||||
icon: Home,
|
||||
href: "/test",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with onClick handler", () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with iconText", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Icon Text Option",
|
||||
description: "Icon Text Description",
|
||||
icon: Home,
|
||||
iconText: "Custom Icon Text",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with loading state", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Loading Option",
|
||||
description: "Loading Description",
|
||||
icon: Home,
|
||||
isLoading: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple options", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "First Option",
|
||||
description: "First Description",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Second Option",
|
||||
description: "Second Description",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick handler when clicking an option", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
await userEvent.click(screen.getByText("Click Option"));
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(ZUserUpdateInput.partial())
|
||||
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, parsedInput);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnterpriseSettingsPage from "./page";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
|
||||
});
|
||||
|
||||
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: EnterpriseSettingsPage } = await import("./page");
|
||||
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
|
||||
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { AnyActionArg } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
@@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => {
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
|
||||
// Arrange
|
||||
const mockSurvey = {
|
||||
questions: [],
|
||||
variables: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
isVerifyEmailEnabled: true,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockT = vi.fn((key) => key);
|
||||
const isExpanded = false;
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
expect(verifiedEmailColumn).toBeDefined();
|
||||
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
|
||||
|
||||
// Call the header function to trigger the t function call with "common.verified_email"
|
||||
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
|
||||
verifiedEmailColumn.header();
|
||||
expect(mockT).toHaveBeenCalledWith("common.verified_email");
|
||||
}
|
||||
});
|
||||
|
||||
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
|
||||
// Arrange
|
||||
const mockSurvey = {
|
||||
questions: [],
|
||||
variables: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
isVerifyEmailEnabled: false,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockT = vi.fn((key) => key);
|
||||
const isExpanded = false;
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
expect(verifiedEmailColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Column Implementations", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("dateColumn renders with formatted date", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
|
||||
expect(dateColumn).toBeDefined();
|
||||
|
||||
// Call the header function to test it returns the expected value
|
||||
expect(dateColumn?.header?.()).toBe("common.date");
|
||||
|
||||
// Mock a response with a date to test the cell function
|
||||
const mockRow = {
|
||||
original: { createdAt: "2023-01-01T12:00:00Z" },
|
||||
} as any;
|
||||
|
||||
// Call the cell function and check the formatted date
|
||||
dateColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
test("personColumn renders anonymous when person is null", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
// Test header content
|
||||
const headerResult = personColumn?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with no person
|
||||
const mockRow = {
|
||||
original: { person: null },
|
||||
} as any;
|
||||
|
||||
// Mock the t function for this specific call
|
||||
t.mockReturnValueOnce("Anonymous User");
|
||||
|
||||
// Call the cell function and check it returns "Anonymous"
|
||||
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(t).toHaveBeenCalledWith("common.anonymous");
|
||||
expect(cellResult?.props?.children).toBe("Anonymous User");
|
||||
});
|
||||
|
||||
test("personColumn renders person identifier when person exists", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
// Mock a response with a person
|
||||
const mockRow = {
|
||||
original: {
|
||||
person: { id: "123", attributes: { email: "test@example.com" } },
|
||||
contactAttributes: { name: "John Doe" },
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
personColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
|
||||
mockRow.original.person,
|
||||
mockRow.original.contactAttributes
|
||||
);
|
||||
});
|
||||
|
||||
test("tagsColumn returns undefined when tags is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
|
||||
expect(tagsColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no tags
|
||||
const mockRow = {
|
||||
original: { tags: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("notesColumn renders when notes is an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with notes
|
||||
const mockRow = {
|
||||
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
|
||||
});
|
||||
|
||||
test("notesColumn returns undefined when notes is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no notes
|
||||
const mockRow = {
|
||||
original: { notes: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("variableColumns render variable values correctly", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Find the variable column for var1
|
||||
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
|
||||
expect(var1Column).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
const headerResult = var1Column?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with a string variable
|
||||
const mockRow = {
|
||||
original: { variables: { var1: "Test Value" } },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult?.props.children).toBe("Test Value");
|
||||
|
||||
// Test with a number variable
|
||||
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
|
||||
expect(var2Column).toBeDefined();
|
||||
|
||||
const mockRowNumber = {
|
||||
original: { variables: { var2: 42 } },
|
||||
} as any;
|
||||
|
||||
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
|
||||
expect(cellResultNumber?.props.children).toBe(42);
|
||||
});
|
||||
|
||||
test("hiddenFieldColumns render when fieldIds exist", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Find the hidden field column
|
||||
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hfColumn).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
const headerResult = hfColumn?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with a hidden field value
|
||||
const mockRow = {
|
||||
original: { responseData: { hf1: "Hidden Value" } },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult?.props.children).toBe("Hidden Value");
|
||||
});
|
||||
|
||||
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
|
||||
// Create a survey with no hidden field IDs
|
||||
const surveyWithNoHiddenFields = {
|
||||
...mockSurvey,
|
||||
hiddenFields: { enabled: true }, // no fieldIds
|
||||
};
|
||||
|
||||
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
|
||||
|
||||
// Check that no hidden field columns were created
|
||||
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hfColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
QuestionsComboBox,
|
||||
SelectedCommandItem,
|
||||
} from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectedCommandItem", () => {
|
||||
test("renders question icon and color for QUESTIONS with questionType", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem
|
||||
label="Q1"
|
||||
type={OptionsType.QUESTIONS}
|
||||
questionType={TSurveyQuestionTypeEnum.OpenText}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Q1");
|
||||
});
|
||||
|
||||
test("renders attribute icon and color for ATTRIBUTES", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Attr");
|
||||
});
|
||||
|
||||
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Hidden");
|
||||
});
|
||||
|
||||
test("renders meta icon and color for META with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("device");
|
||||
});
|
||||
|
||||
test("renders other icon and color for OTHERS with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Language");
|
||||
});
|
||||
|
||||
test("renders tag icon and color for TAGS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Tag1");
|
||||
});
|
||||
|
||||
test("renders fallback color and no icon for unknown type", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).not.toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("renders fallback for non-string label", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
|
||||
);
|
||||
expect(container.textContent).toContain("NonString");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
ListIcon,
|
||||
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
}
|
||||
|
||||
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return <ListOrderedIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return <User width={18} height={18} className="text-white" />;
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return <EyeOff width={18} height={18} className="text-white" />;
|
||||
case OptionsType.META:
|
||||
switch (label) {
|
||||
case "device":
|
||||
return <SmartphoneIcon width={18} height={18} className="text-white" />;
|
||||
case "os":
|
||||
return <AirplayIcon width={18} height={18} className="text-white" />;
|
||||
case "browser":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "source":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "action":
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.OTHERS:
|
||||
switch (label) {
|
||||
case "Language":
|
||||
return <LanguagesIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.TAGS:
|
||||
return <HashIcon width={18} height={18} className="text-white" />;
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
// hidden fields
|
||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||
|
||||
// meta
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useParams } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
|
||||
getSurveyFilterDataAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
generateQuestionAndFilterOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [[vi.fn()]],
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionsComboBox", () => ({
|
||||
QuestionsComboBox: ({ onChangeValue }) => (
|
||||
<div data-testid="questions-combo-box">
|
||||
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
|
||||
Select Question
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
OptionsType: {
|
||||
QUESTIONS: "Questions",
|
||||
ATTRIBUTES: "Attributes",
|
||||
TAGS: "Tags",
|
||||
LANGUAGES: "Languages",
|
||||
},
|
||||
}));
|
||||
|
||||
// Update the mock for QuestionFilterComboBox to always render
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
|
||||
() => ({
|
||||
QuestionFilterComboBox: () => (
|
||||
<div data-testid="filter-combo-box">
|
||||
<button data-testid="select-filter-btn">Select Filter</button>
|
||||
<button data-testid="select-filter-type-btn">Select Filter Type</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
describe("ResponseFilter", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockSelectedFilter = {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
};
|
||||
|
||||
const mockSelectedOptions = {
|
||||
questionFilterOptions: [
|
||||
{
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
filterOptions: ["equals", "does not equal"],
|
||||
filterComboBoxOptions: [],
|
||||
id: "q1",
|
||||
},
|
||||
],
|
||||
questionOptions: [
|
||||
{
|
||||
label: "Questions",
|
||||
type: "Questions",
|
||||
option: [
|
||||
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const mockSetSelectedFilter = vi.fn();
|
||||
const mockSetSelectedOptions = vi.fn();
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
environmentId: "env1",
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
createdBy: "user1",
|
||||
questions: [],
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
triggers: [],
|
||||
displayOption: "displayOnce",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: mockSelectedFilter,
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
|
||||
|
||||
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
|
||||
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
|
||||
questionOptions: mockSelectedOptions.questionOptions,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders with default state", () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
expect(screen.getByText("Filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the filter popover when clicked", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches filter data when opened", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
|
||||
expect(mockSetSelectedOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles adding new filter", async () => {
|
||||
// Start with an empty filter
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: { filter: [], onlyComplete: false },
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
// Verify there's no filter yet
|
||||
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
|
||||
|
||||
// Add a new filter and check that the questions combo box appears
|
||||
await userEvent.click(screen.getByText("common.add_filter"));
|
||||
|
||||
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles only complete checkbox toggle", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByRole("checkbox"));
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
|
||||
});
|
||||
|
||||
test("handles selecting question and filter options", async () => {
|
||||
// Setup with a pre-populated filter to ensure the filter components are rendered
|
||||
const setSelectedFilterMock = vi.fn();
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [
|
||||
{
|
||||
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
|
||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||
},
|
||||
],
|
||||
onlyComplete: false,
|
||||
},
|
||||
setSelectedFilter: setSelectedFilterMock,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
// Verify both combo boxes are rendered
|
||||
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
|
||||
|
||||
// Use data-testid to find our buttons instead of text
|
||||
await userEvent.click(screen.getByText("Select Question"));
|
||||
await userEvent.click(screen.getByTestId("select-filter-btn"));
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(setSelectedFilterMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles clear all filters", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByText("common.clear_all"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
|
||||
});
|
||||
|
||||
test("uses sharing key action when on sharing page", async () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "env1",
|
||||
surveyId: "survey1",
|
||||
sharingKey: "share123",
|
||||
});
|
||||
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
|
||||
sharingKey: "share123",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ForgotPasswordPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/page", () => ({
|
||||
ForgotPasswordPage: () => (
|
||||
<div data-testid="forgot-password-page">
|
||||
<div data-testid="form-wrapper">
|
||||
<div data-testid="forgot-password-form">Forgot Password Form</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the forgot password page", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the form wrapper", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the forgot password form", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ClientEnvironmentRedirect", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should redirect to the provided environment ID when no last environment exists", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
||||
});
|
||||
|
||||
test("should redirect to the last environment ID when it exists in localStorage", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage with a last environment ID
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("last-env-id"),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
||||
});
|
||||
|
||||
test("should update redirect when environment ID prop changes", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
||||
|
||||
// Clear mock calls
|
||||
mockPush.mockClear();
|
||||
|
||||
// Rerender with new environment ID
|
||||
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type FollowUpResult = {
|
||||
followUpId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
const { properties } = followUpAction;
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
|
||||
if (typeof toValueFromResponse === "string") {
|
||||
// parse this string to check for an email:
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
} else if (Array.isArray(toValueFromResponse)) {
|
||||
const emailAddress = toValueFromResponse[2];
|
||||
if (!emailAddress) {
|
||||
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
// Check if we should skip this follow-up based on ending IDs
|
||||
if (trigger.properties) {
|
||||
const { endingIds } = trigger.properties;
|
||||
const { endingId } = response;
|
||||
|
||||
if (!endingId || !endingIds.includes(endingId)) {
|
||||
return Promise.resolve({
|
||||
followUpId: followUp.id,
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
followUpId: followUp.id,
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : "Something went wrong",
|
||||
}));
|
||||
});
|
||||
|
||||
const followUpResults = await Promise.all(followUpPromises);
|
||||
|
||||
// Log all errors
|
||||
const errors = followUpResults
|
||||
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
|
||||
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
// send follow up emails
|
||||
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
|
||||
if (surveyFollowUpsPermission) {
|
||||
await sendSurveyFollowUps(survey, response, organization);
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
|
||||
@@ -102,6 +102,8 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
@@ -116,6 +118,8 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/singleUseSurveys";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ValidationError } from "@formbricks/types/errors";
|
||||
import { deleteWebhook } from "./webhook";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { deleteWebhook, getWebhook } from "./webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -33,9 +34,15 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
// Accept any function and return the exact same generic Fn – keeps typings intact
|
||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||
}));
|
||||
|
||||
describe("deleteWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
|
||||
@@ -105,4 +112,133 @@ describe("deleteWebhook", () => {
|
||||
expect(prisma.webhook.delete).not.toHaveBeenCalled();
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when webhook does not exist", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(deleteWebhook("non-existent-id")).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when database operation fails", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when an unknown error occurs", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Unknown error"));
|
||||
|
||||
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return webhook when it exists", async () => {
|
||||
const mockedWebhook: Webhook = {
|
||||
id: "test-webhook-id",
|
||||
url: "https://example.com",
|
||||
name: "Test Webhook",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
source: "user",
|
||||
environmentId: "test-environment-id",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
|
||||
|
||||
const webhook = await getWebhook("test-webhook-id");
|
||||
|
||||
expect(webhook).toEqual(mockedWebhook);
|
||||
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when webhook does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const webhook = await getWebhook("non-existent-id");
|
||||
|
||||
expect(webhook).toBeNull();
|
||||
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "non-existent-id",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ValidationError when called with invalid webhook ID", async () => {
|
||||
const { validateInputs } = await import("@/lib/utils/validate");
|
||||
(validateInputs as any).mockImplementation(() => {
|
||||
throw new ValidationError("Validation failed");
|
||||
});
|
||||
|
||||
await expect(getWebhook("invalid-id")).rejects.toThrow(ValidationError);
|
||||
expect(prisma.webhook.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when database operation fails", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw original error when an unknown error occurs", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(unknownError);
|
||||
|
||||
await expect(getWebhook("test-webhook-id")).rejects.toThrow(unknownError);
|
||||
});
|
||||
|
||||
test("should use cache when getting webhook", async () => {
|
||||
const mockedWebhook: Webhook = {
|
||||
id: "test-webhook-id",
|
||||
url: "https://example.com",
|
||||
name: "Test Webhook",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
source: "user",
|
||||
environmentId: "test-environment-id",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
|
||||
|
||||
const webhook = await getWebhook("test-webhook-id");
|
||||
|
||||
expect(webhook).toEqual(mockedWebhook);
|
||||
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
endingId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
finished,
|
||||
endingId,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { TolgeeInstance } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import RootLayout from "./layout";
|
||||
import RootLayout, { metadata } from "./layout";
|
||||
|
||||
// Mock dependencies for the layout
|
||||
|
||||
@@ -40,15 +41,6 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTolgee: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
|
||||
<div data-testid="ph-provider">
|
||||
PHProvider: {posthogEnabled}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/client", () => ({
|
||||
TolgeeNextProvider: ({
|
||||
children,
|
||||
@@ -95,10 +87,53 @@ describe("RootLayout", () => {
|
||||
|
||||
const children = <div data-testid="child">Child Content</div>;
|
||||
const element = await RootLayout({ children });
|
||||
render(element);
|
||||
const html = renderToString(element);
|
||||
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
// Create a container and set its innerHTML
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Now we can use screen queries on the rendered content
|
||||
expect(container.querySelector('[data-testid="tolgee-next-provider"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="sentry-provider"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="child"]')).toHaveTextContent("Child Content");
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
test("renders with different locale", async () => {
|
||||
const fakeLocale = "de-DE";
|
||||
vi.mocked(getLocale).mockResolvedValue(fakeLocale);
|
||||
|
||||
const fakeStaticData = { key: "value" };
|
||||
const fakeTolgee = {
|
||||
loadRequired: vi.fn().mockResolvedValue(fakeStaticData),
|
||||
};
|
||||
vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance);
|
||||
|
||||
const children = <div data-testid="child">Child Content</div>;
|
||||
const element = await RootLayout({ children });
|
||||
const html = renderToString(element);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const tolgeeProvider = container.querySelector('[data-testid="tolgee-next-provider"]');
|
||||
expect(tolgeeProvider).toHaveTextContent(fakeLocale);
|
||||
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
test("exports correct metadata", () => {
|
||||
expect(metadata).toEqual({
|
||||
title: {
|
||||
template: "%s | Formbricks",
|
||||
default: "Formbricks",
|
||||
},
|
||||
description: "Open-Source Survey Suite",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal file
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { isValidCssSelector } from "./actionClass";
|
||||
|
||||
describe("isValidCssSelector", () => {
|
||||
beforeEach(() => {
|
||||
// Mock document.createElement and querySelector
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(),
|
||||
};
|
||||
global.document = {
|
||||
createElement: vi.fn(() => mockElement),
|
||||
} as any;
|
||||
});
|
||||
|
||||
test("should return false for undefined selector", () => {
|
||||
expect(isValidCssSelector(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for empty string", () => {
|
||||
expect(isValidCssSelector("")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for valid CSS selector", () => {
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(),
|
||||
};
|
||||
(document.createElement as any).mockReturnValue(mockElement);
|
||||
expect(isValidCssSelector(".class")).toBe(true);
|
||||
expect(isValidCssSelector("#id")).toBe(true);
|
||||
expect(isValidCssSelector("div")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for invalid CSS selector", () => {
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(() => {
|
||||
throw new Error("Invalid selector");
|
||||
}),
|
||||
};
|
||||
(document.createElement as any).mockReturnValue(mockElement);
|
||||
expect(isValidCssSelector("..invalid")).toBe(false);
|
||||
expect(isValidCssSelector("##invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
366
apps/web/app/lib/api/response.test.ts
Normal file
366
apps/web/app/lib/api/response.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { NextApiResponse } from "next";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { responses } from "./response";
|
||||
|
||||
describe("API Response Utilities", () => {
|
||||
describe("successResponse", () => {
|
||||
test("should return a success response with data", () => {
|
||||
const testData = { message: "test" };
|
||||
const response = responses.successResponse(testData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({ data: testData });
|
||||
});
|
||||
});
|
||||
|
||||
test("should include CORS headers when cors is true", () => {
|
||||
const testData = { message: "test" };
|
||||
const response = responses.successResponse(testData, true);
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const testData = { message: "test" };
|
||||
const customCache = "public, max-age=3600";
|
||||
const response = responses.successResponse(testData, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("badRequestResponse", () => {
|
||||
test("should return a bad request response", () => {
|
||||
const message = "Invalid input";
|
||||
const details = { field: "email" };
|
||||
const response = responses.badRequestResponse(message, details);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "bad_request",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Invalid input";
|
||||
const response = responses.badRequestResponse(message);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "bad_request",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Invalid input";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.badRequestResponse(message, undefined, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notFoundResponse", () => {
|
||||
test("should return a not found response", () => {
|
||||
const resourceType = "User";
|
||||
const resourceId = "123";
|
||||
const response = responses.notFoundResponse(resourceType, resourceId);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: `${resourceType} not found`,
|
||||
details: {
|
||||
resource_id: resourceId,
|
||||
resource_type: resourceType,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle null resourceId", () => {
|
||||
const resourceType = "User";
|
||||
const response = responses.notFoundResponse(resourceType, null);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "not_found",
|
||||
message: `${resourceType} not found`,
|
||||
details: {
|
||||
resource_id: null,
|
||||
resource_type: resourceType,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const resourceType = "User";
|
||||
const resourceId = "123";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.notFoundResponse(resourceType, resourceId, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("internalServerErrorResponse", () => {
|
||||
test("should return an internal server error response", () => {
|
||||
const message = "Something went wrong";
|
||||
const response = responses.internalServerErrorResponse(message);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should include CORS headers when cors is true", () => {
|
||||
const message = "Something went wrong";
|
||||
const response = responses.internalServerErrorResponse(message, true);
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Something went wrong";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.internalServerErrorResponse(message, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goneResponse", () => {
|
||||
test("should return a gone response", () => {
|
||||
const message = "Resource no longer available";
|
||||
const details = { reason: "deleted" };
|
||||
const response = responses.goneResponse(message, details);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "gone",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Resource no longer available";
|
||||
const response = responses.goneResponse(message);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "gone",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Resource no longer available";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.goneResponse(message, undefined, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodNotAllowedResponse", () => {
|
||||
test("should return a method not allowed response", () => {
|
||||
const mockRes = {
|
||||
req: { method: "PUT" },
|
||||
} as NextApiResponse;
|
||||
const allowedMethods = ["GET", "POST"];
|
||||
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "method_not_allowed",
|
||||
message: "The HTTP PUT method is not supported by this route.",
|
||||
details: {
|
||||
allowed_methods: allowedMethods,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle missing request method", () => {
|
||||
const mockRes = {} as NextApiResponse;
|
||||
const allowedMethods = ["GET", "POST"];
|
||||
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "method_not_allowed",
|
||||
message: "The HTTP undefined method is not supported by this route.",
|
||||
details: {
|
||||
allowed_methods: allowedMethods,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const mockRes = {
|
||||
req: { method: "PUT" },
|
||||
} as NextApiResponse;
|
||||
const allowedMethods = ["GET", "POST"];
|
||||
const customCache = "no-cache";
|
||||
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notAuthenticatedResponse", () => {
|
||||
test("should return a not authenticated response", () => {
|
||||
const response = responses.notAuthenticatedResponse();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "not_authenticated",
|
||||
message: "Not authenticated",
|
||||
details: {
|
||||
"x-Api-Key": "Header not provided or API Key invalid",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const customCache = "no-cache";
|
||||
const response = responses.notAuthenticatedResponse(false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unauthorizedResponse", () => {
|
||||
test("should return an unauthorized response", () => {
|
||||
const response = responses.unauthorizedResponse();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "unauthorized",
|
||||
message: "You are not authorized to access this resource",
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const customCache = "no-cache";
|
||||
const response = responses.unauthorizedResponse(false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forbiddenResponse", () => {
|
||||
test("should return a forbidden response", () => {
|
||||
const message = "Access denied";
|
||||
const details = { reason: "insufficient_permissions" };
|
||||
const response = responses.forbiddenResponse(message, false, details);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "forbidden",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Access denied";
|
||||
const response = responses.forbiddenResponse(message);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "forbidden",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Access denied";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.forbiddenResponse(message, false, undefined, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("should return a too many requests response", () => {
|
||||
const message = "Rate limit exceeded";
|
||||
const response = responses.tooManyRequestsResponse(message);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "too_many_requests",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Rate limit exceeded";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.tooManyRequestsResponse(message, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
apps/web/app/lib/api/validator.test.ts
Normal file
83
apps/web/app/lib/api/validator.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZodError, ZodIssueCode } from "zod";
|
||||
import { transformErrorToDetails } from "./validator";
|
||||
|
||||
describe("transformErrorToDetails", () => {
|
||||
test("should transform ZodError with a single issue to details object", () => {
|
||||
const error = new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.invalid_type,
|
||||
expected: "string",
|
||||
received: "number",
|
||||
path: ["name"],
|
||||
message: "Expected string, received number",
|
||||
},
|
||||
]);
|
||||
const details = transformErrorToDetails(error);
|
||||
expect(details).toEqual({
|
||||
name: "Expected string, received number",
|
||||
});
|
||||
});
|
||||
|
||||
test("should transform ZodError with multiple issues to details object", () => {
|
||||
const error = new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.invalid_type,
|
||||
expected: "string",
|
||||
received: "number",
|
||||
path: ["name"],
|
||||
message: "Expected string, received number",
|
||||
},
|
||||
{
|
||||
code: ZodIssueCode.too_small,
|
||||
minimum: 5,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
exact: false,
|
||||
message: "String must contain at least 5 character(s)",
|
||||
path: ["address", "street"],
|
||||
},
|
||||
]);
|
||||
const details = transformErrorToDetails(error);
|
||||
expect(details).toEqual({
|
||||
name: "Expected string, received number",
|
||||
"address.street": "String must contain at least 5 character(s)",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return an empty object if ZodError has no issues", () => {
|
||||
const error = new ZodError([]);
|
||||
const details = transformErrorToDetails(error);
|
||||
expect(details).toEqual({});
|
||||
});
|
||||
|
||||
test("should handle issues with empty paths", () => {
|
||||
const error = new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.custom,
|
||||
path: [],
|
||||
message: "Global error",
|
||||
},
|
||||
]);
|
||||
const details = transformErrorToDetails(error);
|
||||
expect(details).toEqual({
|
||||
"": "Global error",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle issues with multi-level paths", () => {
|
||||
const error = new ZodError([
|
||||
{
|
||||
code: ZodIssueCode.invalid_type,
|
||||
expected: "string",
|
||||
received: "undefined",
|
||||
path: ["user", "profile", "firstName"],
|
||||
message: "Required",
|
||||
},
|
||||
]);
|
||||
const details = transformErrorToDetails(error);
|
||||
expect(details).toEqual({
|
||||
"user.profile.firstName": "Required",
|
||||
});
|
||||
});
|
||||
});
|
||||
736
apps/web/app/lib/surveys/surveys.test.ts
Normal file
736
apps/web/app/lib/surveys/surveys.test.ts
Normal file
@@ -0,0 +1,736 @@
|
||||
import {
|
||||
DateRange,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
|
||||
describe("surveys", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("generateQuestionAndFilterOptions", () => {
|
||||
test("should return question options for basic survey without additional options", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text Question" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
|
||||
|
||||
expect(result.questionOptions.length).toBeGreaterThan(0);
|
||||
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
|
||||
expect(result.questionFilterOptions.length).toBe(1);
|
||||
expect(result.questionFilterOptions[0].id).toBe("q1");
|
||||
});
|
||||
|
||||
test("should include tags in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const tags: TTag[] = [
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {});
|
||||
|
||||
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
|
||||
expect(tagsHeader).toBeDefined();
|
||||
expect(tagsHeader?.option.length).toBe(1);
|
||||
expect(tagsHeader?.option[0].label).toBe("Tag 1");
|
||||
});
|
||||
|
||||
test("should include attributes in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const attributes = {
|
||||
role: ["admin", "user"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {});
|
||||
|
||||
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
|
||||
expect(attributesHeader).toBeDefined();
|
||||
expect(attributesHeader?.option.length).toBe(1);
|
||||
expect(attributesHeader?.option[0].label).toBe("role");
|
||||
});
|
||||
|
||||
test("should include meta in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const meta = {
|
||||
source: ["web", "mobile"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
|
||||
|
||||
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
|
||||
expect(metaHeader).toBeDefined();
|
||||
expect(metaHeader?.option.length).toBe(1);
|
||||
expect(metaHeader?.option[0].label).toBe("source");
|
||||
});
|
||||
|
||||
test("should include hidden fields in options when provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const hiddenFields = {
|
||||
segment: ["free", "paid"],
|
||||
};
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields);
|
||||
|
||||
const hiddenFieldsHeader = result.questionOptions.find(
|
||||
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
|
||||
);
|
||||
expect(hiddenFieldsHeader).toBeDefined();
|
||||
expect(hiddenFieldsHeader?.option.length).toBe(1);
|
||||
expect(hiddenFieldsHeader?.option[0].label).toBe("segment");
|
||||
});
|
||||
|
||||
test("should include language options when survey has languages", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
|
||||
|
||||
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
|
||||
expect(othersHeader).toBeDefined();
|
||||
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should handle all question types correctly", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [
|
||||
{ id: "c1", label: "Choice 1" },
|
||||
{ id: "other", label: "Other" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q4",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q5",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q6",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
|
||||
|
||||
expect(result.questionFilterOptions.length).toBe(8);
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "openTextQ",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Open Text" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcSingleQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Multiple Choice Single" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "mcMultiQ",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Multiple Choice Multi" },
|
||||
choices: [{ id: "c1", label: "Choice 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "npsQ",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "NPS" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ratingQ",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rating" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "ctaQ",
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "consentQ",
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "pictureQ",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Picture Selection" },
|
||||
choices: [
|
||||
{ id: "p1", imageUrl: "url1" },
|
||||
{ id: "p2", imageUrl: "url2" },
|
||||
],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "matrixQ",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "addressQ",
|
||||
type: TSurveyQuestionTypeEnum.Address,
|
||||
headline: { default: "Address" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "contactQ",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "rankingQ",
|
||||
type: TSurveyQuestionTypeEnum.Ranking,
|
||||
headline: { default: "Ranking" },
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const dateRange: DateRange = {
|
||||
from: new Date("2023-01-01"),
|
||||
to: new Date("2023-01-31"),
|
||||
};
|
||||
|
||||
test("should return empty filters when no selections", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [],
|
||||
};
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(Object.keys(result).length).toBe(0);
|
||||
});
|
||||
|
||||
test("should filter by completed responses", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: true,
|
||||
filter: [],
|
||||
};
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.finished).toBe(true);
|
||||
});
|
||||
|
||||
test("should filter by date range", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [],
|
||||
};
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.createdAt).toBeDefined();
|
||||
expect(result.createdAt?.min).toEqual(dateRange.from);
|
||||
expect(result.createdAt?.max).toEqual(dateRange.to);
|
||||
});
|
||||
|
||||
test("should filter by tags", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
filterType: { filterComboBoxValue: "Applied" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
|
||||
filterType: { filterComboBoxValue: "Not applied" },
|
||||
},
|
||||
] as any,
|
||||
};
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.tags?.applied).toContain("Tag 1");
|
||||
expect(result.tags?.notApplied).toContain("Tag 2");
|
||||
});
|
||||
|
||||
test("should filter by open text questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Open Text",
|
||||
id: "openTextQ",
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.openTextQ).toEqual({ op: "filledOut" });
|
||||
});
|
||||
|
||||
test("should filter by address questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Address",
|
||||
id: "addressQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Address,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Skipped" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.addressQ).toEqual({ op: "skipped" });
|
||||
});
|
||||
|
||||
test("should filter by contact info questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Contact Info",
|
||||
id: "contactQ",
|
||||
questionType: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.contactQ).toEqual({ op: "filledOut" });
|
||||
});
|
||||
|
||||
test("should filter by ranking questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Ranking",
|
||||
id: "rankingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Ranking,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Filled out" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.rankingQ).toEqual({ op: "submitted" });
|
||||
});
|
||||
|
||||
test("should filter by multiple choice single questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "MC Single",
|
||||
id: "mcSingleQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.mcSingleQ).toEqual({ op: "includesOne", value: ["Choice 1"] });
|
||||
});
|
||||
|
||||
test("should filter by multiple choice multi questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "MC Multi",
|
||||
id: "mcMultiQ",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
},
|
||||
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.mcMultiQ).toEqual({ op: "includesAll", value: ["Choice 1", "Choice 2"] });
|
||||
});
|
||||
|
||||
test("should filter by NPS questions with different operations", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.npsQ).toEqual({ op: "equals", value: 7 });
|
||||
});
|
||||
|
||||
test("should filter by rating questions with less than operation", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Rating",
|
||||
id: "ratingQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
},
|
||||
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.ratingQ).toEqual({ op: "lessThan", value: 4 });
|
||||
});
|
||||
|
||||
test("should filter by CTA questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "CTA",
|
||||
id: "ctaQ",
|
||||
questionType: TSurveyQuestionTypeEnum.CTA,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Clicked" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.ctaQ).toEqual({ op: "clicked" });
|
||||
});
|
||||
|
||||
test("should filter by consent questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Consent",
|
||||
id: "consentQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Consent,
|
||||
},
|
||||
filterType: { filterComboBoxValue: "Accepted" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.consentQ).toEqual({ op: "accepted" });
|
||||
});
|
||||
|
||||
test("should filter by picture selection questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Picture",
|
||||
id: "pictureQ",
|
||||
questionType: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
},
|
||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.pictureQ).toEqual({ op: "includesOne", value: ["p1"] });
|
||||
});
|
||||
|
||||
test("should filter by matrix questions", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "Matrix",
|
||||
id: "matrixQ",
|
||||
questionType: TSurveyQuestionTypeEnum.Matrix,
|
||||
},
|
||||
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.matrixQ).toEqual({ op: "matrix", value: { "Row 1": "Column 1" } });
|
||||
});
|
||||
|
||||
test("should filter by hidden fields", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.data?.plan).toEqual({ op: "equals", value: "pro" });
|
||||
});
|
||||
|
||||
test("should filter by attributes", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Attributes", label: "role", id: "role" },
|
||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.contactAttributes?.role).toEqual({ op: "notEquals", value: "admin" });
|
||||
});
|
||||
|
||||
test("should filter by other filters", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Other Filters", label: "Language", id: "language" },
|
||||
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.others?.Language).toEqual({ op: "equals", value: "en" });
|
||||
});
|
||||
|
||||
test("should filter by meta fields", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: false,
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Meta", label: "source", id: "source" },
|
||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.meta?.source).toEqual({ op: "notEquals", value: "web" });
|
||||
});
|
||||
|
||||
test("should handle multiple filters together", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
onlyComplete: true,
|
||||
filter: [
|
||||
{
|
||||
questionType: {
|
||||
type: "Questions",
|
||||
label: "NPS",
|
||||
id: "npsQ",
|
||||
questionType: TSurveyQuestionTypeEnum.NPS,
|
||||
},
|
||||
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||
filterType: { filterComboBoxValue: "Applied" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
|
||||
expect(result.finished).toBe(true);
|
||||
expect(result.createdAt).toBeDefined();
|
||||
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
|
||||
expect(result.tags?.applied).toContain("Tag 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodayDate", () => {
|
||||
test("should return today's date with time set to end of day", () => {
|
||||
const today = new Date();
|
||||
const result = getTodayDate();
|
||||
|
||||
expect(result.getFullYear()).toBe(today.getFullYear());
|
||||
expect(result.getMonth()).toBe(today.getMonth());
|
||||
expect(result.getDate()).toBe(today.getDate());
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/web/app/middleware/bucket.test.ts
Normal file
99
apps/web/app/middleware/bucket.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||
|
||||
describe("bucket middleware rate limiters", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
const mockedRateLimit = rateLimit as unknown as Mock;
|
||||
mockedRateLimit.mockImplementation((config) => config);
|
||||
});
|
||||
|
||||
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
|
||||
const { loginLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(loginLimiter).toEqual({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
|
||||
const { signupLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(signupLimiter).toEqual({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
|
||||
const { verifyEmailLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(verifyEmailLimiter).toEqual({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
|
||||
const { forgotPasswordLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(forgotPasswordLimiter).toEqual({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
|
||||
const { clientSideApiEndpointsLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(clientSideApiEndpointsLimiter).toEqual({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
|
||||
const { shareUrlLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(shareUrlLimiter).toEqual({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
|
||||
const { syncUserIdentificationLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(syncUserIdentificationLimiter).toEqual({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { rateLimit } from "@/app/middleware/rate-limit";
|
||||
import {
|
||||
CLIENT_SIDE_API_RATE_LIMIT,
|
||||
FORGET_PASSWORD_RATE_LIMIT,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
|
||||
VERIFY_EMAIL_RATE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
|
||||
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal file
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
describe("isLoginRoute", () => {
|
||||
test("should return true for login routes", () => {
|
||||
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
|
||||
expect(isLoginRoute("/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-login routes", () => {
|
||||
expect(isLoginRoute("/auth/signup")).toBe(false);
|
||||
expect(isLoginRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSignupRoute", () => {
|
||||
test("should return true for signup route", () => {
|
||||
expect(isSignupRoute("/auth/signup")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-signup routes", () => {
|
||||
expect(isSignupRoute("/auth/login")).toBe(false);
|
||||
expect(isSignupRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerifyEmailRoute", () => {
|
||||
test("should return true for verify email route", () => {
|
||||
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-verify email routes", () => {
|
||||
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
|
||||
expect(isVerifyEmailRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForgotPasswordRoute", () => {
|
||||
test("should return true for forgot password route", () => {
|
||||
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-forgot password routes", () => {
|
||||
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
|
||||
expect(isForgotPasswordRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isManagementApiRoute", () => {
|
||||
test("should return true for management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/api/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/auth/login")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShareUrlRoute", () => {
|
||||
test("should return true for share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123")).toBe(false);
|
||||
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
|
||||
expect(isShareUrlRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthProtectedRoute", () => {
|
||||
test("should return true for protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/environments")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/api/something")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncWithUserIdentificationEndpoint", () => {
|
||||
test("should return environmentId and userId for valid sync URLs", () => {
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "abc-123",
|
||||
userId: "xyz-789",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid sync URLs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export const isLoginRoute = (url: string) => url === "/api/auth/callback/credentials";
|
||||
export const isLoginRoute = (url: string) =>
|
||||
url === "/api/auth/callback/credentials" || url === "/auth/login";
|
||||
|
||||
export const isSignupRoute = (url: string) => url === "/auth/signup";
|
||||
|
||||
|
||||
37
apps/web/app/not-found.test.tsx
Normal file
37
apps/web/app/not-found.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import NotFound from "./not-found";
|
||||
|
||||
describe("NotFound", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders 404 page with correct content", () => {
|
||||
render(<NotFound />);
|
||||
|
||||
// Check for the 404 text
|
||||
const errorCode = screen.getByTestId("error-code");
|
||||
expect(errorCode).toBeInTheDocument();
|
||||
expect(errorCode).toHaveClass("text-sm", "font-semibold");
|
||||
expect(errorCode).toHaveTextContent("404");
|
||||
|
||||
// Check for the heading
|
||||
const heading = screen.getByRole("heading", { name: "Page not found" });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading).toHaveClass("mt-2", "text-2xl", "font-bold");
|
||||
|
||||
// Check for the error message
|
||||
const errorMessage = screen.getByTestId("error-message");
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
expect(errorMessage).toHaveClass("mt-2", "text-base");
|
||||
expect(errorMessage).toHaveTextContent("Sorry, we couldn't find the page you're looking for.");
|
||||
|
||||
// Check for the button
|
||||
const button = screen.getByRole("button", { name: "Back to home" });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("mt-8");
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,18 @@ import Link from "next/link";
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
Sorry, we couldn’t find the page you’re looking for.
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">Back to home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white" data-testid="error-code">
|
||||
404
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400" data-testid="error-message">
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">Back to home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
391
apps/web/app/page.test.tsx
Normal file
391
apps/web/app/page.test.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getFirstEnvironmentIdByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/instance/service", () => ({
|
||||
getIsFreshInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/client-logout", () => ({
|
||||
ClientLogout: () => <div data-testid="client-logout">Client Logout</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
|
||||
default: ({ environmentId }: { environmentId: string }) => (
|
||||
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to setup/intro when no session and fresh instance", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { redirect } = await import("next/navigation");
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(true);
|
||||
|
||||
await Page();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/setup/intro");
|
||||
});
|
||||
|
||||
test("redirects to auth/login when no session and not fresh instance", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { redirect } = await import("next/navigation");
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
|
||||
await Page();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("shows client logout when user is not found", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { render } = await import("@testing-library/react");
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
|
||||
const result = await Page();
|
||||
const { container } = render(result);
|
||||
|
||||
expect(container.querySelector('[data-testid="client-logout"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("redirects to organization creation when user has no organizations", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { redirect } = await import("next/navigation");
|
||||
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: null,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US",
|
||||
lastLoginAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([]);
|
||||
|
||||
await Page();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/setup/organization/create");
|
||||
});
|
||||
|
||||
test("redirects to project creation when user has organizations but no environment", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { redirect } = await import("next/navigation");
|
||||
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: null,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US",
|
||||
lastLoginAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
isOwner: true,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
|
||||
await Page();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/projects/new/mode`);
|
||||
});
|
||||
|
||||
test("redirects to landing when user has organizations but no environment and is not owner/manager", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { redirect } = await import("next/navigation");
|
||||
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: null,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US",
|
||||
lastLoginAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
|
||||
await Page();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/landing`);
|
||||
});
|
||||
|
||||
test("renders ClientEnvironmentRedirect when user has environment", async () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { render } = await import("@testing-library/react");
|
||||
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: null,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US",
|
||||
lastLoginAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
|
||||
const result = await Page();
|
||||
const { container } = render(result);
|
||||
|
||||
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
|
||||
`Environment ID: ${mockEnvironmentId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,9 @@ const Page = async () => {
|
||||
|
||||
if (!session) {
|
||||
if (isFreshInstance) {
|
||||
redirect("/setup/intro");
|
||||
return redirect("/setup/intro");
|
||||
} else {
|
||||
redirect("/auth/login");
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,16 @@ import Link from "next/link";
|
||||
const NotFound = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("share.page_not_found_description")}
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">{t("share.back_to_home")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("share.page_not_found_description")}
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">{t("share.back_to_home")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
97
apps/web/cache-handler.js
Normal file
97
apps/web/cache-handler.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// This cache handler follows the @fortedigital/nextjs-cache-handler example
|
||||
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
|
||||
|
||||
// @neshca/cache-handler dependencies
|
||||
const { CacheHandler } = require("@neshca/cache-handler");
|
||||
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
|
||||
|
||||
// Next/Redis dependencies
|
||||
const { createClient } = require("redis");
|
||||
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
|
||||
|
||||
// @fortedigital/nextjs-cache-handler dependencies
|
||||
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
|
||||
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
|
||||
|
||||
// Usual onCreation from @neshca/cache-handler
|
||||
CacheHandler.onCreation(() => {
|
||||
// Important - It's recommended to use global scope to ensure only one Redis connection is made
|
||||
// This ensures only one instance get created
|
||||
if (global.cacheHandlerConfig) {
|
||||
return global.cacheHandlerConfig;
|
||||
}
|
||||
|
||||
// Important - It's recommended to use global scope to ensure only one Redis connection is made
|
||||
// This ensures new instances are not created in a race condition
|
||||
if (global.cacheHandlerConfigPromise) {
|
||||
return global.cacheHandlerConfigPromise;
|
||||
}
|
||||
|
||||
// If REDIS_URL is not set, we will use LRU cache only
|
||||
if (!process.env.REDIS_URL) {
|
||||
const lruCache = createLruHandler();
|
||||
return { handlers: [lruCache] };
|
||||
}
|
||||
|
||||
// Main promise initializing the handler
|
||||
global.cacheHandlerConfigPromise = (async () => {
|
||||
/** @type {import("redis").RedisClientType | null} */
|
||||
let redisClient = null;
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
|
||||
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
|
||||
const settings = {
|
||||
url: process.env.REDIS_URL, // Make sure you configure this variable
|
||||
pingInterval: 10000,
|
||||
};
|
||||
|
||||
try {
|
||||
redisClient = createClient(settings);
|
||||
redisClient.on("error", (e) => {
|
||||
console.error("Redis error", e);
|
||||
global.cacheHandlerConfig = null;
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create Redis client:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (redisClient) {
|
||||
try {
|
||||
console.info("Connecting Redis client...");
|
||||
await redisClient.connect();
|
||||
console.info("Redis client connected.");
|
||||
} catch (error) {
|
||||
console.error("Failed to connect Redis client:", error);
|
||||
await redisClient
|
||||
.disconnect()
|
||||
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
|
||||
}
|
||||
}
|
||||
const lruCache = createLruHandler();
|
||||
|
||||
if (!redisClient?.isReady) {
|
||||
console.error("Failed to initialize caching layer.");
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
global.cacheHandlerConfig = { handlers: [lruCache] };
|
||||
return global.cacheHandlerConfig;
|
||||
}
|
||||
|
||||
const redisCacheHandler = createRedisHandler({
|
||||
client: redisClient,
|
||||
keyPrefix: "nextjs:",
|
||||
});
|
||||
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
|
||||
global.cacheHandlerConfig = {
|
||||
handlers: [redisCacheHandler],
|
||||
};
|
||||
|
||||
return global.cacheHandlerConfig;
|
||||
})();
|
||||
|
||||
return global.cacheHandlerConfigPromise;
|
||||
});
|
||||
|
||||
module.exports = new Next15CacheHandler();
|
||||
@@ -1,79 +0,0 @@
|
||||
import { CacheHandler } from "@neshca/cache-handler";
|
||||
import createLruHandler from "@neshca/cache-handler/local-lru";
|
||||
import createRedisHandler from "@neshca/cache-handler/redis-strings";
|
||||
import { createClient } from "redis";
|
||||
|
||||
// Function to create a timeout promise
|
||||
const createTimeoutPromise = (ms, rejectReason) => {
|
||||
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
|
||||
};
|
||||
|
||||
CacheHandler.onCreation(async () => {
|
||||
let client;
|
||||
|
||||
if (process.env.REDIS_URL) {
|
||||
try {
|
||||
// Create a Redis client.
|
||||
client = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
|
||||
// Redis won't work without error handling.
|
||||
client.on("error", () => {});
|
||||
} catch (error) {
|
||||
console.warn("Failed to create Redis client:", error);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
try {
|
||||
// Wait for the client to connect with a timeout of 5000ms.
|
||||
const connectPromise = client.connect();
|
||||
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.warn("Failed to connect Redis client:", error);
|
||||
|
||||
console.warn("Disconnecting the Redis client...");
|
||||
// Try to disconnect the client to stop it from reconnecting.
|
||||
client
|
||||
.disconnect()
|
||||
.then(() => {
|
||||
console.info("Redis client disconnected.");
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Failed to quit the Redis client after failing to connect.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("@neshca/cache-handler").Handler | null} */
|
||||
let handler;
|
||||
|
||||
if (client?.isReady) {
|
||||
const redisHandlerOptions = {
|
||||
client,
|
||||
keyPrefix: "fb:",
|
||||
timeoutMs: 1000,
|
||||
};
|
||||
|
||||
// Create the `redis-stack` Handler if the client is available and connected.
|
||||
handler = await createRedisHandler(redisHandlerOptions);
|
||||
} else {
|
||||
// Fallback to LRU handler if Redis client is not available.
|
||||
// The application will still work, but the cache will be in memory only and not shared.
|
||||
handler = createLruHandler();
|
||||
console.log("Using LRU handler for caching.");
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: [handler],
|
||||
ttl: {
|
||||
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
|
||||
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
|
||||
estimateExpireAge: (staleAge) => staleAge,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default CacheHandler;
|
||||
@@ -1,34 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { actionClassCache } from "./cache";
|
||||
import { getActionClass } from "./service";
|
||||
|
||||
export const canUserUpdateActionClass = (userId: string, actionClassId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [actionClassId, ZId]);
|
||||
|
||||
try {
|
||||
if (!userId) return false;
|
||||
|
||||
const actionClass = await getActionClass(actionClassId);
|
||||
if (!actionClass) return false;
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, actionClass.environmentId);
|
||||
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
[`canUserUpdateActionClass-${userId}-${actionClassId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)();
|
||||
213
apps/web/lib/actionClass/service.test.ts
Normal file
213
apps/web/lib/actionClass/service.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { actionClassCache } from "./cache";
|
||||
import {
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClasses,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
actionClass: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
actionClassCache: {
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn(),
|
||||
byNameAndEnvironmentId: vi.fn(),
|
||||
byId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ActionClass Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getActionClasses", () => {
|
||||
test("should return action classes for environment", async () => {
|
||||
const mockActionClasses = [
|
||||
{
|
||||
id: "id1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 1",
|
||||
description: "desc",
|
||||
type: "code",
|
||||
key: "key1",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env1",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getActionClasses("env1");
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: "env1" },
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
|
||||
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActionClassByEnvironmentIdAndName", () => {
|
||||
test("should return action class when found", async () => {
|
||||
const mockActionClass = {
|
||||
id: "id2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 2",
|
||||
description: "desc2",
|
||||
type: "noCode",
|
||||
key: null,
|
||||
noCodeConfig: {},
|
||||
environmentId: "env2",
|
||||
};
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
|
||||
where: { name: "Action 2", environmentId: "env2" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when not found", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActionClass", () => {
|
||||
test("should return action class when found", async () => {
|
||||
const mockActionClass = {
|
||||
id: "id3",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 3",
|
||||
description: "desc3",
|
||||
type: "code",
|
||||
key: "key3",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env3",
|
||||
};
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClass("id3");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "id3" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when not found", async () => {
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(null);
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClass("id3");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("fail"));
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClass("id3")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteActionClass", () => {
|
||||
test("should delete and return action class", async () => {
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "id4",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 4",
|
||||
description: null,
|
||||
type: "code",
|
||||
key: "key4",
|
||||
noCodeConfig: null,
|
||||
environmentId: "env4",
|
||||
};
|
||||
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
|
||||
vi.mocked(prisma.actionClass.delete).mockResolvedValue(mockActionClass);
|
||||
vi.mocked(actionClassCache.revalidate).mockReturnValue(undefined);
|
||||
const result = await deleteActionClass("id4");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.delete).toHaveBeenCalledWith({
|
||||
where: { id: "id4" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(actionClassCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockActionClass.environmentId,
|
||||
id: "id4",
|
||||
name: mockActionClass.name,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if action class is null", async () => {
|
||||
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
|
||||
vi.mocked(prisma.actionClass.delete).mockResolvedValue(null as unknown as TActionClass);
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
|
||||
const error = new Error("unknown");
|
||||
vi.mocked(prisma.actionClass.delete).mockRejectedValue(error);
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
219
apps/web/lib/auth.test.ts
Normal file
219
apps/web/lib/auth.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
hasOrganizationAccess,
|
||||
hasOrganizationAuthority,
|
||||
hasOrganizationOwnership,
|
||||
hashPassword,
|
||||
isManagerOrOwner,
|
||||
isOwner,
|
||||
verifyPassword,
|
||||
} from "./auth";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Password Management", () => {
|
||||
test("hashPassword should hash a password", async () => {
|
||||
const password = "testPassword123";
|
||||
const hashedPassword = await hashPassword(password);
|
||||
expect(hashedPassword).toBeDefined();
|
||||
expect(hashedPassword).not.toBe(password);
|
||||
});
|
||||
|
||||
test("verifyPassword should verify a correct password", async () => {
|
||||
const password = "testPassword123";
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const isValid = await verifyPassword(password, hashedPassword);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyPassword should reject an incorrect password", async () => {
|
||||
const password = "testPassword123";
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const isValid = await verifyPassword("wrongPassword", hashedPassword);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Access", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("hasOrganizationAccess should return true when user has membership", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("hasOrganizationAccess should return false when user has no membership", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
test("isManagerOrOwner should return true for manager role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
expect(isManager).toBe(true);
|
||||
});
|
||||
|
||||
test("isManagerOrOwner should return true for owner role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
expect(isOwner).toBe(true);
|
||||
});
|
||||
|
||||
test("isManagerOrOwner should return false for member role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
expect(isManagerOrOwnerRole).toBe(false);
|
||||
});
|
||||
|
||||
test("isOwner should return true only for owner role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
|
||||
expect(isOwnerRole).toBe(true);
|
||||
});
|
||||
|
||||
test("isOwner should return false for non-owner roles", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
|
||||
expect(isOwnerRole).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Authority", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("hasOrganizationAuthority should return true for manager", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
|
||||
expect(hasAuthority).toBe(true);
|
||||
});
|
||||
|
||||
test("hasOrganizationAuthority should throw for non-member", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("hasOrganizationAuthority should throw for member role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("hasOrganizationOwnership should return true for owner", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
|
||||
expect(hasOwnership).toBe(true);
|
||||
});
|
||||
|
||||
test("hasOrganizationOwnership should throw for non-member", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
});
|
||||
66
apps/web/lib/cache/document.ts
vendored
66
apps/web/lib/cache/document.ts
vendored
@@ -1,66 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string | null;
|
||||
surveyId?: string | null;
|
||||
responseId?: string | null;
|
||||
questionId?: string | null;
|
||||
insightId?: string | null;
|
||||
}
|
||||
|
||||
export const documentCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `documents-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-documents`;
|
||||
},
|
||||
byResponseId(responseId: string) {
|
||||
return `responses-${responseId}-documents`;
|
||||
},
|
||||
byResponseIdQuestionId(responseId: string, questionId: TSurveyQuestionId) {
|
||||
return `responses-${responseId}-questions-${questionId}-documents`;
|
||||
},
|
||||
bySurveyId(surveyId: string) {
|
||||
return `surveys-${surveyId}-documents`;
|
||||
},
|
||||
bySurveyIdQuestionId(surveyId: string, questionId: TSurveyQuestionId) {
|
||||
return `surveys-${surveyId}-questions-${questionId}-documents`;
|
||||
},
|
||||
byInsightId(insightId: string) {
|
||||
return `insights-${insightId}-documents`;
|
||||
},
|
||||
byInsightIdSurveyIdQuestionId(insightId: string, surveyId: string, questionId: TSurveyQuestionId) {
|
||||
return `insights-${insightId}-surveys-${surveyId}-questions-${questionId}-documents`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(documentCache.tag.byId(id));
|
||||
}
|
||||
if (environmentId) {
|
||||
revalidateTag(documentCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
if (responseId) {
|
||||
revalidateTag(documentCache.tag.byResponseId(responseId));
|
||||
}
|
||||
if (surveyId) {
|
||||
revalidateTag(documentCache.tag.bySurveyId(surveyId));
|
||||
}
|
||||
if (responseId && questionId) {
|
||||
revalidateTag(documentCache.tag.byResponseIdQuestionId(responseId, questionId));
|
||||
}
|
||||
if (surveyId && questionId) {
|
||||
revalidateTag(documentCache.tag.bySurveyIdQuestionId(surveyId, questionId));
|
||||
}
|
||||
if (insightId) {
|
||||
revalidateTag(documentCache.tag.byInsightId(insightId));
|
||||
}
|
||||
if (insightId && surveyId && questionId) {
|
||||
revalidateTag(documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId));
|
||||
}
|
||||
},
|
||||
};
|
||||
25
apps/web/lib/cache/insight.ts
vendored
25
apps/web/lib/cache/insight.ts
vendored
@@ -1,25 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const insightCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `documentGroups-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-documentGroups`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, environmentId }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(insightCache.tag.byId(id));
|
||||
}
|
||||
if (environmentId) {
|
||||
revalidateTag(insightCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -205,7 +205,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
|
||||
export const REDIS_DEFAULT_TTL = env.REDIS_DEFAULT_TTL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ export const env = createEnv({
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
REDIS_DEFAULT_TTL: z.string().optional(),
|
||||
REDIS_HTTP_URL: z.string().optional(),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
POSTHOG_API_HOST: z.string().optional(),
|
||||
@@ -163,7 +162,6 @@ export const env = createEnv({
|
||||
OIDC_ISSUER: process.env.OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL,
|
||||
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
|
||||
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
|
||||
PRIVACY_URL: process.env.PRIVACY_URL,
|
||||
|
||||
86
apps/web/lib/environment/auth.test.ts
Normal file
86
apps/web/lib/environment/auth.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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 { hasUserEnvironmentAccess } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
teamUser: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("hasUserEnvironmentAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns true for owner role", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
role: "owner",
|
||||
} as any);
|
||||
|
||||
const result = await hasUserEnvironmentAccess("user1", "env1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for manager role", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
role: "manager",
|
||||
} as any);
|
||||
|
||||
const result = await hasUserEnvironmentAccess("user1", "env1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for billing role", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
role: "billing",
|
||||
} as any);
|
||||
|
||||
const result = await hasUserEnvironmentAccess("user1", "env1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when user has team membership", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
role: "member",
|
||||
} as any);
|
||||
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue({
|
||||
userId: "user1",
|
||||
} as any);
|
||||
|
||||
const result = await hasUserEnvironmentAccess("user1", "env1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when user has no access", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
role: "member",
|
||||
} as any);
|
||||
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await hasUserEnvironmentAccess("user1", "env1");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.membership.findFirst).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(hasUserEnvironmentAccess("user1", "env1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
206
apps/web/lib/environment/service.test.ts
Normal file
206
apps/web/lib/environment/service.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { EnvironmentType, Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { environmentCache } from "./cache";
|
||||
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
environmentCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byId: vi.fn(),
|
||||
byProjectId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Environment Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getEnvironment", () => {
|
||||
test("should return environment when found", async () => {
|
||||
const mockEnvironment = {
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
type: EnvironmentType.production,
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
widgetSetupCompleted: false,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
|
||||
|
||||
expect(result).toEqual(mockEnvironment);
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when environment not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironment("clh6pzwx90000e9ogjr0mf7sx")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironments", () => {
|
||||
test("should return environments when project exists", async () => {
|
||||
const mockEnvironments = [
|
||||
{
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
type: EnvironmentType.production,
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
{
|
||||
id: "clh6pzwx90000e9ogjr0mf7sz",
|
||||
type: EnvironmentType.development,
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue({
|
||||
id: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
...mockEnvironments[0],
|
||||
widgetSetupCompleted: false,
|
||||
},
|
||||
{
|
||||
...mockEnvironments[1],
|
||||
widgetSetupCompleted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironments("clh6pzwx90000e9ogjr0mf7sy");
|
||||
|
||||
expect(result).toEqual(mockEnvironments);
|
||||
expect(prisma.project.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when project not found", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateEnvironment", () => {
|
||||
test("should update environment successfully", async () => {
|
||||
const mockEnvironment = {
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
type: EnvironmentType.production,
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
widgetSetupCompleted: false,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.update).mockResolvedValue(mockEnvironment);
|
||||
|
||||
const updateData = {
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const result = await updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", updateData);
|
||||
|
||||
expect(result).toEqual(mockEnvironment);
|
||||
expect(prisma.environment.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
appSetupCompleted: true,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
expect(environmentCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.update).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", { appSetupCompleted: true })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
export const fetchRessource = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
|
||||
// If the status code is not in the range 200-299,
|
||||
// we still try to parse and throw it.
|
||||
if (!res.ok) {
|
||||
const error: any = new Error("An error occurred while fetching the data.");
|
||||
// Attach extra info to the error object.
|
||||
error.info = await res.json();
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const fetcher = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
|
||||
// If the status code is not in the range 200-299,
|
||||
// we still try to parse and throw it.
|
||||
if (!res.ok) {
|
||||
const error: any = new Error("An error occurred while fetching the data.");
|
||||
// Attach extra info to the error object.
|
||||
error.info = await res.json();
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const updateRessource = async (url: string, { arg }: { arg: any }) => {
|
||||
return fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
};
|
||||
46
apps/web/lib/getSurveyUrl.test.ts
Normal file
46
apps/web/lib/getSurveyUrl.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Create a mock module for constants with proper types
|
||||
const constantsMock = {
|
||||
SURVEY_URL: undefined as string | undefined,
|
||||
WEBAPP_URL: "http://localhost:3000" as string,
|
||||
};
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock("./constants", () => constantsMock);
|
||||
|
||||
describe("getSurveyDomain", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the mock values before each test
|
||||
constantsMock.SURVEY_URL = undefined;
|
||||
constantsMock.WEBAPP_URL = "http://localhost:3000";
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should return SURVEY_URL when it is set", async () => {
|
||||
constantsMock.SURVEY_URL = "https://surveys.example.com";
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("https://surveys.example.com");
|
||||
});
|
||||
|
||||
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||
constantsMock.SURVEY_URL = "";
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||
constantsMock.SURVEY_URL = undefined;
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
51
apps/web/lib/hashString.test.ts
Normal file
51
apps/web/lib/hashString.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashString } from "./hashString";
|
||||
|
||||
describe("hashString", () => {
|
||||
test("should return a string", () => {
|
||||
const input = "test string";
|
||||
const hash = hashString(input);
|
||||
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should produce consistent hashes for the same input", () => {
|
||||
const input = "test string";
|
||||
const hash1 = hashString(input);
|
||||
const hash2 = hashString(input);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("should handle empty strings", () => {
|
||||
const hash = hashString("");
|
||||
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should handle special characters", () => {
|
||||
const input = "!@#$%^&*()_+{}|:<>?";
|
||||
const hash = hashString(input);
|
||||
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should handle unicode characters", () => {
|
||||
const input = "Hello, 世界!";
|
||||
const hash = hashString(input);
|
||||
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should handle long strings", () => {
|
||||
const input = "a".repeat(1000);
|
||||
const hash = hashString(input);
|
||||
|
||||
expect(typeof hash).toBe("string");
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockSurveyLanguages } from "@/lib/survey/tests/__mock__/survey.mock";
|
||||
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import "server-only";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { TI18nString } from "@formbricks/types/surveys/types";
|
||||
import { isI18nObject } from "./utils";
|
||||
|
||||
// Helper function to extract a regular string from an i18nString.
|
||||
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
|
||||
if (typeof i18nString === "object" && i18nString !== null) {
|
||||
return i18nString[languageCode] || "";
|
||||
}
|
||||
return i18nString;
|
||||
};
|
||||
|
||||
// Assuming I18nString and extraction logic are defined
|
||||
const reverseTranslateObject = <T extends Record<string, any>>(obj: T, languageCode: string): T => {
|
||||
const clonedObj = structuredClone(obj);
|
||||
for (let key in clonedObj) {
|
||||
const value = clonedObj[key];
|
||||
if (isI18nObject(value)) {
|
||||
// Now TypeScript knows `value` is I18nString, treat it accordingly
|
||||
clonedObj[key] = extractStringFromI18n(value, languageCode) as T[Extract<keyof T, string>];
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
// Recursively handle nested objects
|
||||
clonedObj[key] = reverseTranslateObject(value, languageCode);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import "server-only";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { cache } from "../cache";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { getIntegration } from "./service";
|
||||
|
||||
export const canUserAccessIntegration = async (userId: string, integrationId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [integrationId, ZId]);
|
||||
if (!userId) return false;
|
||||
|
||||
try {
|
||||
const integration = await getIntegration(integrationId);
|
||||
if (!integration) return false;
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, integration.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
[`canUserAccessIntegration-${userId}-${integrationId}`],
|
||||
{
|
||||
tags: [`integrations-${integrationId}`],
|
||||
}
|
||||
)();
|
||||
291
apps/web/lib/integration/service.test.ts
Normal file
291
apps/web/lib/integration/service.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { IntegrationType, 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 { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration,
|
||||
getIntegration,
|
||||
getIntegrationByType,
|
||||
getIntegrations,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
integration: {
|
||||
upsert: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Integration Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockIntegrationConfig = {
|
||||
email: "test@example.com",
|
||||
key: {
|
||||
scope: "https://www.googleapis.com/auth/spreadsheets",
|
||||
token_type: "Bearer" as const,
|
||||
expiry_date: 1234567890,
|
||||
access_token: "mock-access-token",
|
||||
refresh_token: "mock-refresh-token",
|
||||
},
|
||||
data: [
|
||||
{
|
||||
spreadsheetId: "spreadsheet123",
|
||||
spreadsheetName: "Test Spreadsheet",
|
||||
surveyId: "survey123",
|
||||
surveyName: "Test Survey",
|
||||
questionIds: ["q1", "q2"],
|
||||
questions: "Question 1, Question 2",
|
||||
createdAt: new Date(),
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true,
|
||||
includeVariables: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createOrUpdateIntegration", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockIntegrationData: TIntegrationInput = {
|
||||
type: "googleSheets",
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should create a new integration", async () => {
|
||||
const mockIntegration = {
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
...mockIntegrationData,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.integration.upsert).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData);
|
||||
|
||||
expect(prisma.integration.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockIntegrationData.type,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...mockIntegrationData,
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
},
|
||||
create: {
|
||||
...mockIntegrationData,
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.upsert).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrations", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockIntegrations = [
|
||||
{
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
},
|
||||
];
|
||||
|
||||
test("should get all integrations for an environment", async () => {
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const result = await getIntegrations(mockEnvironmentId);
|
||||
|
||||
expect(prisma.integration.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegrations);
|
||||
});
|
||||
|
||||
test("should get paginated integrations", async () => {
|
||||
const page = 2;
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const result = await getIntegrations(mockEnvironmentId, page);
|
||||
|
||||
expect(prisma.integration.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegrations);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegrations(mockEnvironmentId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegration", () => {
|
||||
const mockIntegrationId = "int_123";
|
||||
const mockIntegration = {
|
||||
id: mockIntegrationId,
|
||||
environmentId: "clg123456789012345678901234",
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should get an integration by ID", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await getIntegration(mockIntegrationId);
|
||||
|
||||
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockIntegrationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should return null when integration is not found", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getIntegration(mockIntegrationId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationByType", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockType = IntegrationType.googleSheets;
|
||||
const mockIntegration = {
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockType,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should get an integration by type", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await getIntegrationByType(mockEnvironmentId, mockType);
|
||||
|
||||
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should return null when integration is not found", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getIntegrationByType(mockEnvironmentId, mockType);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegrationByType(mockEnvironmentId, mockType)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIntegration", () => {
|
||||
const mockIntegrationId = "int_123";
|
||||
const mockIntegration = {
|
||||
id: mockIntegrationId,
|
||||
environmentId: "clg123456789012345678901234",
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should delete an integration", async () => {
|
||||
vi.mocked(prisma.integration.delete).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await deleteIntegration(mockIntegrationId);
|
||||
|
||||
expect(prisma.integration.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockIntegrationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import { cache } from "../cache";
|
||||
|
||||
195
apps/web/lib/jwt.test.ts
Normal file
195
apps/web/lib/jwt.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyInviteToken,
|
||||
verifyToken,
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
} as typeof env,
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions", () => {
|
||||
const mockUser = {
|
||||
id: "test-user-id",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe("createToken", () => {
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createToken(mockUser.id, mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTokenForLinkSurvey", () => {
|
||||
test("should create a valid survey link token", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createTokenForLinkSurvey("test-survey-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEmailToken", () => {
|
||||
test("should create a valid email token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
(env as any).NEXTAUTH_SECRET = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
||||
} finally {
|
||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => getEmailFromEmailToken("invalid-token")).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInviteToken", () => {
|
||||
test("should create a valid invite token", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createInviteToken("test-invite-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTokenForLinkSurvey", () => {
|
||||
test("should verify valid survey link token", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const verifiedEmail = verifyTokenForLinkSurvey(token, surveyId);
|
||||
expect(verifiedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should return null for invalid token", () => {
|
||||
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
test("should verify valid token", async () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
await expect(verifyToken(token)).rejects.toThrow("User not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
test("should verify valid invite token", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
const verified = verifyInviteToken(token);
|
||||
expect(verified).toEqual({
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid token", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal file
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getMembershipByUserIdOrganizationIdAction } from "./actions";
|
||||
import { useMembershipRole } from "./useMembershipRole";
|
||||
|
||||
vi.mock("./actions", () => ({
|
||||
getMembershipByUserIdOrganizationIdAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useMembershipRole", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should fetch and return membership role", async () => {
|
||||
const mockRole: TOrganizationRole = "owner";
|
||||
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockResolvedValue(mockRole);
|
||||
|
||||
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.membershipRole).toBe(mockRole);
|
||||
expect(result.current.error).toBe("");
|
||||
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
|
||||
});
|
||||
|
||||
test("should handle error when fetching membership role fails", async () => {
|
||||
const errorMessage = "Failed to fetch role";
|
||||
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const useMembershipRole = (environmentId: string, userId: string) => {
|
||||
} catch (err: any) {
|
||||
const error = err?.message || "Something went wrong";
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
getRole();
|
||||
|
||||
184
apps/web/lib/membership/service.test.ts
Normal file
184
apps/web/lib/membership/service.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { membershipCache } from "./cache";
|
||||
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
membershipCache: {
|
||||
tag: {
|
||||
byUserId: vi.fn(),
|
||||
byOrganizationId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Membership Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getMembershipByUserIdOrganizationId", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
|
||||
test("returns membership when found", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
|
||||
expect(result).toEqual(mockMembership);
|
||||
expect(prisma.membership.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when membership not found", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws UnknownError on unknown error", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(new Error("Unknown error"));
|
||||
|
||||
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMembership", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
const mockMembershipData: Partial<TMembership> = {
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
|
||||
test("creates new membership when none exists", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
const mockCreatedMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.create).mockResolvedValue(mockCreatedMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
|
||||
expect(result).toEqual(mockCreatedMembership);
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
accepted: mockMembershipData.accepted,
|
||||
role: mockMembershipData.role,
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns existing membership if role matches", async () => {
|
||||
const existingMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
|
||||
expect(result).toEqual(existingMembership);
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled();
|
||||
expect(prisma.membership.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates existing membership if role differs", async () => {
|
||||
const existingMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
const updatedMembership = {
|
||||
...existingMembership,
|
||||
role: "owner",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
|
||||
vi.mocked(prisma.membership.update).mockResolvedValue(updatedMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, { ...mockMembershipData, role: "owner" });
|
||||
expect(result).toEqual(updatedMembership);
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
accepted: mockMembershipData.accepted,
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createMembership(mockOrgId, mockUserId, mockMembershipData)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/membership/utils.test.ts
Normal file
59
apps/web/lib/membership/utils.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "./utils";
|
||||
|
||||
describe("getAccessFlags", () => {
|
||||
test("should return correct flags for owner role", () => {
|
||||
const role: TOrganizationRole = "owner";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: true,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for manager role", () => {
|
||||
const role: TOrganizationRole = "manager";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: true,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for billing role", () => {
|
||||
const role: TOrganizationRole = "billing";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: true,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for member role", () => {
|
||||
const role: TOrganizationRole = "member";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return all flags as false when role is undefined", () => {
|
||||
const flags = getAccessFlags(undefined);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/web/lib/organization/auth.test.ts
Normal file
129
apps/web/lib/organization/auth.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { getMembershipByUserIdOrganizationId } from "../membership/service";
|
||||
import { getAccessFlags } from "../membership/utils";
|
||||
import { canUserAccessOrganization, verifyUserRoleAccess } from "./auth";
|
||||
import { getOrganizationsByUserId } from "./service";
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("auth", () => {
|
||||
describe("canUserAccessOrganization", () => {
|
||||
test("returns true when user has access to organization", async () => {
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
{
|
||||
id: "org1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Org 1",
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
const result = await canUserAccessOrganization("user1", "org1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyUserRoleAccess", () => {
|
||||
test("returns all access for owner role", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: true,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
|
||||
const result = await verifyUserRoleAccess("org1", "user1");
|
||||
expect(result).toEqual({
|
||||
hasCreateOrUpdateAccess: true,
|
||||
hasDeleteAccess: true,
|
||||
hasCreateOrUpdateMembersAccess: true,
|
||||
hasDeleteMembersAccess: true,
|
||||
hasBillingAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns limited access for manager role", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "manager",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isManager: true,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
|
||||
const result = await verifyUserRoleAccess("org1", "user1");
|
||||
expect(result).toEqual({
|
||||
hasCreateOrUpdateAccess: false,
|
||||
hasDeleteAccess: false,
|
||||
hasCreateOrUpdateMembersAccess: true,
|
||||
hasDeleteMembersAccess: true,
|
||||
hasBillingAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns no access for member role", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
|
||||
const result = await verifyUserRoleAccess("org1", "user1");
|
||||
expect(result).toEqual({
|
||||
hasCreateOrUpdateAccess: false,
|
||||
hasDeleteAccess: false,
|
||||
hasCreateOrUpdateMembersAccess: false,
|
||||
hasDeleteMembersAccess: false,
|
||||
hasBillingAccess: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
273
apps/web/lib/organization/service.test.ts
Normal file
273
apps/web/lib/organization/service.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { organizationCache } from "./cache";
|
||||
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
organizationCache: {
|
||||
tag: {
|
||||
byId: vi.fn(),
|
||||
byUserId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Organization Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getOrganization", () => {
|
||||
test("should return organization when found", async () => {
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly" as const,
|
||||
},
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization);
|
||||
|
||||
const result = await getOrganization("org1");
|
||||
|
||||
expect(result).toEqual(mockOrganization);
|
||||
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "org1" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when organization not found", async () => {
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getOrganization("nonexistent");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.organization.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getOrganization("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationsByUserId", () => {
|
||||
test("should return organizations for user", async () => {
|
||||
const mockOrganizations = [
|
||||
{
|
||||
id: "org1",
|
||||
name: "Test Org 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly" as const,
|
||||
},
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
|
||||
|
||||
const result = await getOrganizationsByUserId("user1");
|
||||
|
||||
expect(result).toEqual(mockOrganizations);
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
userId: "user1",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrganization", () => {
|
||||
test("should create organization with default billing settings", async () => {
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly" as const,
|
||||
},
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.organization.create).mockResolvedValue(mockOrganization);
|
||||
|
||||
const result = await createOrganization({ name: "Test Org" });
|
||||
|
||||
expect(result).toEqual(mockOrganization);
|
||||
expect(prisma.organization.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Org",
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: expect.any(Date),
|
||||
period: "monthly",
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(organizationCache.revalidate).toHaveBeenCalledWith({
|
||||
id: mockOrganization.id,
|
||||
count: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.organization.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createOrganization({ name: "Test Org" })).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateOrganization", () => {
|
||||
test("should update organization and revalidate cache", async () => {
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Updated Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly" as const,
|
||||
},
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user1" }, { userId: "user2" }],
|
||||
projects: [
|
||||
{
|
||||
environments: [{ id: "env1" }, { id: "env2" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.organization.update).mockResolvedValue(mockOrganization);
|
||||
|
||||
const result = await updateOrganization("org1", { name: "Updated Org" });
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "org1",
|
||||
name: "Updated Org",
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
billing: {
|
||||
plan: PROJECT_FEATURE_KEYS.FREE,
|
||||
limits: {
|
||||
projects: BILLING_LIMITS.FREE.PROJECTS,
|
||||
monthly: {
|
||||
responses: BILLING_LIMITS.FREE.RESPONSES,
|
||||
miu: BILLING_LIMITS.FREE.MIU,
|
||||
},
|
||||
},
|
||||
stripeCustomerId: null,
|
||||
periodStart: expect.any(Date),
|
||||
period: "monthly",
|
||||
},
|
||||
isAIEnabled: false,
|
||||
whitelabel: false,
|
||||
});
|
||||
expect(prisma.organization.update).toHaveBeenCalledWith({
|
||||
where: { id: "org1" },
|
||||
data: { name: "Updated Org" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(organizationCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "org1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
421
apps/web/lib/project/service.test.ts
Normal file
421
apps/web/lib/project/service.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
membership: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Project Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("getProject should return a project when it exists", async () => {
|
||||
const mockProject = {
|
||||
id: createId(),
|
||||
name: "Test Project",
|
||||
organizationId: createId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
|
||||
|
||||
const result = await getProject(mockProject.id);
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(prisma.project.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockProject.id,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProject should return null when project does not exist", async () => {
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getProject(createId());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("getProject should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProject(createId())).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should return a project when it exists", async () => {
|
||||
const mockProject = {
|
||||
id: createId(),
|
||||
name: "Test Project",
|
||||
organizationId: createId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
|
||||
|
||||
const result = await getProjectByEnvironmentId(createId());
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(prisma.project.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environments: {
|
||||
some: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should return null when project does not exist", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getProjectByEnvironmentId(createId());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectByEnvironmentId(createId())).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("getUserProjects should return projects for admin user", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 2",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getUserProjects(userId, organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getUserProjects should return projects for member user", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getUserProjects(userId, organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getUserProjects should throw ValidationError when user is not a member of organization", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue(null);
|
||||
|
||||
await expect(getUserProjects(userId, organizationId)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("getUserProjects should handle pagination", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const page = 2;
|
||||
const result = await getUserProjects(userId, organizationId, page);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should return all projects for an organization", async () => {
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 2",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getProjects(organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should handle pagination", async () => {
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const page = 2;
|
||||
const result = await getProjects(organizationId, page);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should throw DatabaseError when prisma throws", async () => {
|
||||
const organizationId = createId();
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import type { TProject } from "@formbricks/types/project";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { responseCache } from "./cache";
|
||||
import { getResponse } from "./service";
|
||||
|
||||
export const canUserAccessResponse = (userId: string, responseId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [responseId, ZId]);
|
||||
|
||||
if (!userId) return false;
|
||||
|
||||
try {
|
||||
const response = await getResponse(responseId);
|
||||
if (!response) return false;
|
||||
|
||||
const survey = await getSurvey(response.surveyId);
|
||||
if (!survey) return false;
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`canUserAccessResponse-${userId}-${responseId}`],
|
||||
{
|
||||
tags: [responseCache.tag.byId(responseId)],
|
||||
}
|
||||
)();
|
||||
@@ -22,7 +22,7 @@ import { responseNoteCache } from "../responseNote/cache";
|
||||
import { getResponseNotes } from "../responseNote/service";
|
||||
import { deleteFile, putFile } from "../storage/service";
|
||||
import { getSurvey } from "../survey/service";
|
||||
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
|
||||
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { responseCache } from "./cache";
|
||||
import {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
mockContactAttributeKey,
|
||||
mockOrganizationOutput,
|
||||
mockSurveyOutput,
|
||||
} from "../../survey/tests/__mock__/survey.mock";
|
||||
} from "../../survey/__mock__/survey.mock";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
|
||||
557
apps/web/lib/response/utils.test.ts
Normal file
557
apps/web/lib/response/utils.test.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildWhereClause,
|
||||
calculateTtcTotal,
|
||||
extracMetadataKeys,
|
||||
extractSurveyDetails,
|
||||
generateAllPermutationsOfSubsets,
|
||||
getResponseContactAttributes,
|
||||
getResponseHiddenFields,
|
||||
getResponseMeta,
|
||||
getResponsesFileName,
|
||||
getResponsesJson,
|
||||
} from "./utils";
|
||||
|
||||
describe("Response Utils", () => {
|
||||
describe("calculateTtcTotal", () => {
|
||||
test("should calculate total time correctly", () => {
|
||||
const ttc = {
|
||||
question1: 10,
|
||||
question2: 20,
|
||||
question3: 30,
|
||||
};
|
||||
const result = calculateTtcTotal(ttc);
|
||||
expect(result._total).toBe(60);
|
||||
});
|
||||
|
||||
test("should handle empty ttc object", () => {
|
||||
const ttc = {};
|
||||
const result = calculateTtcTotal(ttc);
|
||||
expect(result._total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
createdBy: "user1",
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
test("should build where clause with finished filter", () => {
|
||||
const filterCriteria = { finished: true };
|
||||
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
|
||||
expect(result.AND).toContainEqual({ finished: true });
|
||||
});
|
||||
|
||||
test("should build where clause with date range", () => {
|
||||
const filterCriteria = {
|
||||
createdAt: {
|
||||
min: new Date("2024-01-01"),
|
||||
max: new Date("2024-12-31"),
|
||||
},
|
||||
};
|
||||
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
|
||||
expect(result.AND).toContainEqual({
|
||||
createdAt: {
|
||||
gte: new Date("2024-01-01"),
|
||||
lte: new Date("2024-12-31"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should build where clause with tags", () => {
|
||||
const filterCriteria = {
|
||||
tags: {
|
||||
applied: ["tag1", "tag2"],
|
||||
notApplied: ["tag3"],
|
||||
},
|
||||
};
|
||||
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
|
||||
expect(result.AND).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should build where clause with contact attributes", () => {
|
||||
const filterCriteria = {
|
||||
contactAttributes: {
|
||||
email: { op: "equals" as const, value: "test@example.com" },
|
||||
},
|
||||
};
|
||||
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
|
||||
expect(result.AND).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause – others & meta filters", () => {
|
||||
const baseSurvey: Partial<TSurvey> = {
|
||||
id: "s1",
|
||||
name: "Survey",
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "e1",
|
||||
createdBy: "u1",
|
||||
status: "inProgress",
|
||||
};
|
||||
|
||||
test("others: equals & notEquals", () => {
|
||||
const criteria = {
|
||||
others: {
|
||||
Language: { op: "equals" as const, value: "en" },
|
||||
Region: { op: "notEquals" as const, value: "APAC" },
|
||||
},
|
||||
};
|
||||
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
|
||||
expect(result.AND).toEqual([
|
||||
{
|
||||
AND: [{ language: "en" }, { region: { not: "APAC" } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("meta: equals & notEquals map to userAgent paths", () => {
|
||||
const criteria = {
|
||||
meta: {
|
||||
browser: { op: "equals" as const, value: "Chrome" },
|
||||
os: { op: "notEquals" as const, value: "Windows" },
|
||||
},
|
||||
};
|
||||
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
|
||||
expect(result.AND).toEqual([
|
||||
{
|
||||
AND: [
|
||||
{ meta: { path: ["userAgent", "browser"], equals: "Chrome" } },
|
||||
{ meta: { path: ["userAgent", "os"], not: "Windows" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause – data‐field filter operations", () => {
|
||||
const textSurvey: Partial<TSurvey> = {
|
||||
id: "s2",
|
||||
name: "TextSurvey",
|
||||
questions: [
|
||||
{
|
||||
id: "qText",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Text Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "qNum",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Num Q" },
|
||||
required: false,
|
||||
isDraft: false,
|
||||
charLimit: {},
|
||||
inputType: "number",
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "e2",
|
||||
createdBy: "u2",
|
||||
status: "inProgress",
|
||||
};
|
||||
|
||||
const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [
|
||||
["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }],
|
||||
["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }],
|
||||
["skipped", { op: "skipped" }, "OR"],
|
||||
["equals", { op: "equals", value: "foo" }, { path: ["qText"], equals: "foo" }],
|
||||
["notEquals", { op: "notEquals", value: "bar" }, "NOT"],
|
||||
["lessThan", { op: "lessThan", value: 5 }, { path: ["qNum"], lt: 5 }],
|
||||
["lessEqual", { op: "lessEqual", value: 10 }, { path: ["qNum"], lte: 10 }],
|
||||
["greaterThan", { op: "greaterThan", value: 1 }, { path: ["qNum"], gt: 1 }],
|
||||
["greaterEqual", { op: "greaterEqual", value: 2 }, { path: ["qNum"], gte: 2 }],
|
||||
[
|
||||
"includesAll",
|
||||
{ op: "includesAll", value: ["a", "b"] },
|
||||
{ path: ["qText"], array_contains: ["a", "b"] },
|
||||
],
|
||||
];
|
||||
|
||||
ops.forEach(([name, filter, expected]) => {
|
||||
test(name as string, () => {
|
||||
const result = buildWhereClause(textSurvey as TSurvey, {
|
||||
data: {
|
||||
[["submitted", "filledOut", "equals", "includesAll"].includes(name as string) ? "qText" : "qNum"]:
|
||||
filter,
|
||||
},
|
||||
});
|
||||
// for OR/NOT cases we just ensure the operator key exists
|
||||
if (expected === "OR" || expected === "NOT") {
|
||||
expect(JSON.stringify(result)).toMatch(
|
||||
new RegExp(name === "skipped" ? `"OR":\\s*\\[` : `"not":"${filter.value}"`)
|
||||
);
|
||||
} else {
|
||||
expect(result.AND).toEqual([
|
||||
{
|
||||
AND: [{ data: expected }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("uploaded & notUploaded", () => {
|
||||
const res1 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "uploaded" } } });
|
||||
expect(res1.AND).toContainEqual({
|
||||
AND: [{ data: { path: ["qText"], not: "skipped" } }],
|
||||
});
|
||||
|
||||
const res2 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "notUploaded" } } });
|
||||
expect(JSON.stringify(res2)).toMatch(/"equals":"skipped"/);
|
||||
expect(JSON.stringify(res2)).toMatch(/"equals":{}/);
|
||||
});
|
||||
|
||||
test("clicked, accepted & booked", () => {
|
||||
["clicked", "accepted", "booked"].forEach((status) => {
|
||||
const key = status as "clicked" | "accepted" | "booked";
|
||||
const res = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: key } } });
|
||||
expect(res.AND).toEqual([{ AND: [{ data: { path: ["qText"], equals: status } }] }]);
|
||||
});
|
||||
});
|
||||
|
||||
test("matrix", () => {
|
||||
const matrixSurvey: Partial<TSurvey> = {
|
||||
id: "s3",
|
||||
name: "MatrixSurvey",
|
||||
questions: [
|
||||
{
|
||||
id: "qM",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
required: false,
|
||||
rows: [{ default: "R1" }],
|
||||
columns: [{ default: "C1" }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "e3",
|
||||
createdBy: "u3",
|
||||
status: "inProgress",
|
||||
};
|
||||
const res = buildWhereClause(matrixSurvey as TSurvey, {
|
||||
data: { qM: { op: "matrix", value: { R1: "foo" } } },
|
||||
});
|
||||
expect(res.AND).toEqual([
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
data: { path: ["qM", "R1"], equals: "foo" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesFileName", () => {
|
||||
test("should generate correct filename", () => {
|
||||
const surveyName = "Test Survey";
|
||||
const extension = "csv";
|
||||
const result = getResponsesFileName(surveyName, extension);
|
||||
expect(result).toContain("export-test_survey-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extracMetadataKeys", () => {
|
||||
test("should extract metadata keys correctly", () => {
|
||||
const meta = {
|
||||
userAgent: { browser: "Chrome", os: "Windows", device: "Desktop" },
|
||||
country: "US",
|
||||
source: "direct",
|
||||
};
|
||||
const result = extracMetadataKeys(meta);
|
||||
expect(result).toContain("userAgent - browser");
|
||||
expect(result).toContain("userAgent - os");
|
||||
expect(result).toContain("userAgent - device");
|
||||
expect(result).toContain("country");
|
||||
expect(result).toContain("source");
|
||||
});
|
||||
|
||||
test("should handle empty metadata", () => {
|
||||
const result = extracMetadataKeys({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSurveyDetails", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [{ default: "Row 1" }, { default: "Row 2" }],
|
||||
columns: [{ default: "Column 1" }, { default: "Column 2" }],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
createdBy: "user1",
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
const mockResponses: Partial<TResponse>[] = [
|
||||
{
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
data: {},
|
||||
meta: { userAgent: { browser: "Chrome" } },
|
||||
contactAttributes: { email: "test@example.com" },
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notes: [],
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
test("should extract survey details correctly", () => {
|
||||
const result = extractSurveyDetails(mockSurvey as TSurvey, mockResponses as TResponse[]);
|
||||
expect(result.metaDataFields).toContain("userAgent - browser");
|
||||
expect(result.questions).toHaveLength(2); // 1 regular question + 2 matrix rows
|
||||
expect(result.hiddenFields).toContain("hidden1");
|
||||
expect(result.userAttributes).toContain("email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesJson", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
isDraft: false,
|
||||
},
|
||||
],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
createdBy: "user1",
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
const mockResponses: Partial<TResponse>[] = [
|
||||
{
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
data: { q1: "answer1" },
|
||||
meta: { userAgent: { browser: "Chrome" } },
|
||||
contactAttributes: { email: "test@example.com" },
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notes: [],
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
test("should generate correct JSON data", () => {
|
||||
const questionsHeadlines = [["1. Question 1"]];
|
||||
const userAttributes = ["email"];
|
||||
const hiddenFields: string[] = [];
|
||||
const result = getResponsesJson(
|
||||
mockSurvey as TSurvey,
|
||||
mockResponses as TResponse[],
|
||||
questionsHeadlines,
|
||||
userAttributes,
|
||||
hiddenFields
|
||||
);
|
||||
expect(result[0]["Response ID"]).toBe("response1");
|
||||
expect(result[0]["userAgent - browser"]).toBe("Chrome");
|
||||
expect(result[0]["1. Question 1"]).toBe("answer1");
|
||||
expect(result[0]["email"]).toBe("test@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponseContactAttributes", () => {
|
||||
test("should extract contact attributes correctly", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: { email: "test1@example.com", name: "Test 1" },
|
||||
data: {},
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
contactAttributes: { email: "test2@example.com", name: "Test 2" },
|
||||
data: {},
|
||||
meta: {},
|
||||
},
|
||||
];
|
||||
const result = getResponseContactAttributes(
|
||||
responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]
|
||||
);
|
||||
expect(result.email).toContain("test1@example.com");
|
||||
expect(result.email).toContain("test2@example.com");
|
||||
expect(result.name).toContain("Test 1");
|
||||
expect(result.name).toContain("Test 2");
|
||||
});
|
||||
|
||||
test("should handle empty responses", () => {
|
||||
const result = getResponseContactAttributes([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponseMeta", () => {
|
||||
test("should extract meta data correctly", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
userAgent: { browser: "Chrome", os: "Windows" },
|
||||
country: "US",
|
||||
},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: {},
|
||||
meta: {
|
||||
userAgent: { browser: "Firefox", os: "MacOS" },
|
||||
country: "UK",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||
expect(result.browser).toContain("Chrome");
|
||||
expect(result.browser).toContain("Firefox");
|
||||
expect(result.os).toContain("Windows");
|
||||
expect(result.os).toContain("MacOS");
|
||||
});
|
||||
|
||||
test("should handle empty responses", () => {
|
||||
const result = getResponseMeta([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponseHiddenFields", () => {
|
||||
const mockSurvey: Partial<TSurvey> = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
type: "app",
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1", "hidden2"] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
createdBy: "user1",
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
test("should extract hidden fields correctly", () => {
|
||||
const responses = [
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: { hidden1: "value1", hidden2: "value2" },
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
contactAttributes: {},
|
||||
data: { hidden1: "value3", hidden2: "value4" },
|
||||
meta: {},
|
||||
},
|
||||
];
|
||||
const result = getResponseHiddenFields(
|
||||
mockSurvey as TSurvey,
|
||||
responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]
|
||||
);
|
||||
expect(result.hidden1).toContain("value1");
|
||||
expect(result.hidden1).toContain("value3");
|
||||
expect(result.hidden2).toContain("value2");
|
||||
expect(result.hidden2).toContain("value4");
|
||||
});
|
||||
|
||||
test("should handle empty responses", () => {
|
||||
const result = getResponseHiddenFields(mockSurvey as TSurvey, []);
|
||||
expect(result).toEqual({
|
||||
hidden1: [],
|
||||
hidden2: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateAllPermutationsOfSubsets", () => {
|
||||
test("with empty array returns empty", () => {
|
||||
expect(generateAllPermutationsOfSubsets([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("with two elements returns 4 permutations", () => {
|
||||
const out = generateAllPermutationsOfSubsets(["x", "y"]);
|
||||
expect(out).toEqual(expect.arrayContaining([["x"], ["y"], ["x", "y"], ["y", "x"]]));
|
||||
expect(out).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -683,7 +683,7 @@ export const getResponseHiddenFields = (
|
||||
}
|
||||
};
|
||||
|
||||
const generateAllPermutationsOfSubsets = (array: string[]): string[][] => {
|
||||
export const generateAllPermutationsOfSubsets = (array: string[]): string[][] => {
|
||||
const subsets: string[][] = [];
|
||||
|
||||
// Helper function to generate permutations of an array
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { canUserAccessResponse } from "../response/auth";
|
||||
import { getResponse } from "../response/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { responseNoteCache } from "./cache";
|
||||
import { getResponseNote } from "./service";
|
||||
|
||||
export const canUserModifyResponseNote = async (userId: string, responseNoteId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [responseNoteId, ZId]);
|
||||
|
||||
if (!userId || !responseNoteId) return false;
|
||||
|
||||
try {
|
||||
const responseNote = await getResponseNote(responseNoteId);
|
||||
if (!responseNote) return false;
|
||||
|
||||
return responseNote.user.id === userId;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`canUserModifyResponseNote-${userId}-${responseNoteId}`],
|
||||
{
|
||||
tags: [responseNoteCache.tag.byId(responseNoteId)],
|
||||
}
|
||||
)();
|
||||
|
||||
export const canUserResolveResponseNote = async (
|
||||
userId: string,
|
||||
responseId: string,
|
||||
responseNoteId: string
|
||||
): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [responseNoteId, ZId]);
|
||||
|
||||
if (!userId || !responseId || !responseNoteId) return false;
|
||||
|
||||
try {
|
||||
const response = await getResponse(responseId);
|
||||
|
||||
let noteExistsOnResponse = false;
|
||||
|
||||
response?.notes.forEach((note) => {
|
||||
if (note.id === responseNoteId) {
|
||||
noteExistsOnResponse = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!noteExistsOnResponse) return false;
|
||||
|
||||
const canAccessResponse = await canUserAccessResponse(userId, responseId);
|
||||
|
||||
return canAccessResponse;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`canUserResolveResponseNote-${userId}-${responseNoteId}`],
|
||||
{
|
||||
tags: [responseNoteCache.tag.byId(responseNoteId)],
|
||||
}
|
||||
)();
|
||||
@@ -4,8 +4,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseNote } from "@formbricks/types/responses";
|
||||
import { responseCache } from "../response/cache";
|
||||
|
||||
353
apps/web/lib/responses.test.ts
Normal file
353
apps/web/lib/responses.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { convertResponseValue, getQuestionResponseMapping, processResponseData } from "./responses";
|
||||
|
||||
// Mock the recall and i18n utils
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
parseRecallInfo: vi.fn((text) => text),
|
||||
}));
|
||||
|
||||
vi.mock("./i18n/utils", () => ({
|
||||
getLocalizedValue: vi.fn((obj, lang) => obj[lang] || obj.default),
|
||||
}));
|
||||
|
||||
describe("Response Processing", () => {
|
||||
describe("processResponseData", () => {
|
||||
test("should handle string input", () => {
|
||||
expect(processResponseData("test")).toBe("test");
|
||||
});
|
||||
|
||||
test("should handle number input", () => {
|
||||
expect(processResponseData(42)).toBe("42");
|
||||
});
|
||||
|
||||
test("should handle array input", () => {
|
||||
expect(processResponseData(["a", "b", "c"])).toBe("a; b; c");
|
||||
});
|
||||
|
||||
test("should filter out empty values from array", () => {
|
||||
const input = ["a", "", "c"];
|
||||
expect(processResponseData(input)).toBe("a; c");
|
||||
});
|
||||
|
||||
test("should handle object input", () => {
|
||||
const input = { key1: "value1", key2: "value2" };
|
||||
expect(processResponseData(input)).toBe("key1: value1\nkey2: value2");
|
||||
});
|
||||
|
||||
test("should filter out empty values from object", () => {
|
||||
const input = { key1: "value1", key2: "", key3: "value3" };
|
||||
expect(processResponseData(input)).toBe("key1: value1\nkey3: value3");
|
||||
});
|
||||
|
||||
test("should return empty string for unsupported types", () => {
|
||||
expect(processResponseData(undefined as any)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertResponseValue", () => {
|
||||
const mockOpenTextQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
};
|
||||
|
||||
const mockRankingQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.Ranking as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Choice 1" } },
|
||||
{ id: "2", label: { default: "Choice 2" } },
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
};
|
||||
|
||||
const mockFileUploadQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.FileUpload as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMultipleFiles: true,
|
||||
};
|
||||
|
||||
const mockPictureSelectionQuestion = {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection as const,
|
||||
headline: { default: "Test Question" },
|
||||
required: true,
|
||||
allowMulti: false,
|
||||
choices: [
|
||||
{ id: "1", imageUrl: "image1.jpg", label: { default: "Choice 1" } },
|
||||
{ id: "2", imageUrl: "image2.jpg", label: { default: "Choice 2" } },
|
||||
],
|
||||
};
|
||||
|
||||
test("should handle ranking type with string input", () => {
|
||||
expect(convertResponseValue("answer", mockRankingQuestion)).toEqual(["answer"]);
|
||||
});
|
||||
|
||||
test("should handle ranking type with array input", () => {
|
||||
expect(convertResponseValue(["answer1", "answer2"], mockRankingQuestion)).toEqual([
|
||||
"answer1",
|
||||
"answer2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle fileUpload type with string input", () => {
|
||||
expect(convertResponseValue("file.jpg", mockFileUploadQuestion)).toEqual(["file.jpg"]);
|
||||
});
|
||||
|
||||
test("should handle fileUpload type with array input", () => {
|
||||
expect(convertResponseValue(["file1.jpg", "file2.jpg"], mockFileUploadQuestion)).toEqual([
|
||||
"file1.jpg",
|
||||
"file2.jpg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle pictureSelection type with string input", () => {
|
||||
expect(convertResponseValue("1", mockPictureSelectionQuestion)).toEqual(["image1.jpg"]);
|
||||
});
|
||||
|
||||
test("should handle pictureSelection type with array input", () => {
|
||||
expect(convertResponseValue(["1", "2"], mockPictureSelectionQuestion)).toEqual([
|
||||
"image1.jpg",
|
||||
"image2.jpg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle pictureSelection type with invalid choice", () => {
|
||||
expect(convertResponseValue("invalid", mockPictureSelectionQuestion)).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle default case with string input", () => {
|
||||
expect(convertResponseValue("answer", mockOpenTextQuestion)).toBe("answer");
|
||||
});
|
||||
|
||||
test("should handle default case with number input", () => {
|
||||
expect(convertResponseValue(42, mockOpenTextQuestion)).toBe("42");
|
||||
});
|
||||
|
||||
test("should handle default case with array input", () => {
|
||||
expect(convertResponseValue(["a", "b", "c"], mockOpenTextQuestion)).toBe("a; b; c");
|
||||
});
|
||||
|
||||
test("should handle default case with object input", () => {
|
||||
const input = { key1: "value1", key2: "value2" };
|
||||
expect(convertResponseValue(input, mockOpenTextQuestion)).toBe("key1: value1\nkey2: value2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuestionResponseMapping", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
type: "link" as const,
|
||||
status: "inProgress" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
createdBy: null,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "1", label: { default: "Option 1" } },
|
||||
{ id: "2", label: { default: "Option 2" } },
|
||||
],
|
||||
shuffleOption: "none" as const,
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
fieldIds: [],
|
||||
},
|
||||
displayOption: "displayOnce" as const,
|
||||
delay: 0,
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
id: "lang1",
|
||||
code: "default",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
alias: null,
|
||||
projectId: "proj1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
variables: [],
|
||||
endings: [],
|
||||
displayLimit: null,
|
||||
autoClose: null,
|
||||
autoComplete: null,
|
||||
recontactDays: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
displayPercentage: 100,
|
||||
styling: null,
|
||||
projectOverwrites: null,
|
||||
verifyEmail: null,
|
||||
inlineTriggers: [],
|
||||
pin: null,
|
||||
triggers: [],
|
||||
followUps: [],
|
||||
segment: null,
|
||||
recaptcha: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: true,
|
||||
data: {
|
||||
q1: "Answer 1",
|
||||
q2: ["Option 1", "Option 2"],
|
||||
},
|
||||
language: "default",
|
||||
meta: {
|
||||
url: undefined,
|
||||
country: undefined,
|
||||
action: undefined,
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
ttc: {},
|
||||
variables: {},
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
};
|
||||
|
||||
test("should map questions to responses correctly", () => {
|
||||
const mapping = getQuestionResponseMapping(mockSurvey, mockResponse);
|
||||
expect(mapping).toHaveLength(2);
|
||||
expect(mapping[0]).toEqual({
|
||||
question: "Question 1",
|
||||
response: "Answer 1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
});
|
||||
expect(mapping[1]).toEqual({
|
||||
question: "Question 2",
|
||||
response: "Option 1; Option 2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle missing response data", () => {
|
||||
const response = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: true,
|
||||
data: {},
|
||||
language: "default",
|
||||
meta: {
|
||||
url: undefined,
|
||||
country: undefined,
|
||||
action: undefined,
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
ttc: {},
|
||||
variables: {},
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
};
|
||||
const mapping = getQuestionResponseMapping(mockSurvey, response);
|
||||
expect(mapping).toHaveLength(2);
|
||||
expect(mapping[0].response).toBe("");
|
||||
expect(mapping[1].response).toBe("");
|
||||
});
|
||||
|
||||
test("should handle different language", () => {
|
||||
const survey = {
|
||||
...mockSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText as const,
|
||||
headline: { default: "Question 1", en: "Question 1 EN" },
|
||||
required: true,
|
||||
inputType: "text" as const,
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: true,
|
||||
data: { q1: "Answer 1" },
|
||||
language: "en",
|
||||
meta: {
|
||||
url: undefined,
|
||||
country: undefined,
|
||||
action: undefined,
|
||||
source: undefined,
|
||||
userAgent: undefined,
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
person: null,
|
||||
personAttributes: {},
|
||||
ttc: {},
|
||||
variables: {},
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
};
|
||||
const mapping = getQuestionResponseMapping(survey, response);
|
||||
expect(mapping[0].question).toBe("Question 1 EN");
|
||||
});
|
||||
});
|
||||
});
|
||||
134
apps/web/lib/storage/service.test.ts
Normal file
134
apps/web/lib/storage/service.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock AWS SDK
|
||||
const mockSend = vi.fn();
|
||||
const mockS3Client = {
|
||||
send: mockSend,
|
||||
};
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(() => mockS3Client),
|
||||
HeadBucketCommand: vi.fn(),
|
||||
PutObjectCommand: vi.fn(),
|
||||
DeleteObjectCommand: vi.fn(),
|
||||
GetObjectCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("../constants", () => ({
|
||||
S3_ACCESS_KEY: "test-access-key",
|
||||
S3_SECRET_KEY: "test-secret-key",
|
||||
S3_REGION: "test-region",
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
S3_ENDPOINT_URL: "http://test-endpoint",
|
||||
S3_FORCE_PATH_STYLE: true,
|
||||
isS3Configured: () => true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
MAX_SIZES: {
|
||||
standard: 5 * 1024 * 1024,
|
||||
big: 10 * 1024 * 1024,
|
||||
},
|
||||
WEBAPP_URL: "http://test-webapp",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
|
||||
UPLOADS_DIR: "/tmp/uploads",
|
||||
}));
|
||||
|
||||
// Mock crypto functions
|
||||
vi.mock("crypto", () => ({
|
||||
randomUUID: () => "test-uuid",
|
||||
}));
|
||||
|
||||
describe("Storage Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getS3Client", () => {
|
||||
test("should create and return S3 client instance", async () => {
|
||||
const { getS3Client } = await import("./service");
|
||||
const client = getS3Client();
|
||||
expect(client).toBe(mockS3Client);
|
||||
expect(S3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: "test-access-key",
|
||||
secretAccessKey: "test-secret-key",
|
||||
},
|
||||
region: "test-region",
|
||||
endpoint: "http://test-endpoint",
|
||||
forcePathStyle: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return existing client instance on subsequent calls", async () => {
|
||||
vi.resetModules();
|
||||
const { getS3Client } = await import("./service");
|
||||
const client1 = getS3Client();
|
||||
const client2 = getS3Client();
|
||||
expect(client1).toBe(client2);
|
||||
expect(S3Client).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testS3BucketAccess", () => {
|
||||
let testS3BucketAccess: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const serviceModule = await import("./service");
|
||||
testS3BucketAccess = serviceModule.testS3BucketAccess;
|
||||
});
|
||||
|
||||
test("should return true when bucket access is successful", async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
const result = await testS3BucketAccess();
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw error when bucket access fails", async () => {
|
||||
const error = new Error("Access denied");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
await expect(testS3BucketAccess()).rejects.toThrow(
|
||||
"S3 Bucket Access Test Failed: Error: Access denied"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putFile", () => {
|
||||
let putFile: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const serviceModule = await import("./service");
|
||||
putFile = serviceModule.putFile;
|
||||
});
|
||||
|
||||
test("should successfully upload file to S3", async () => {
|
||||
const fileName = "test.jpg";
|
||||
const fileBuffer = Buffer.from("test");
|
||||
const accessType = "private";
|
||||
const environmentId = "env123";
|
||||
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
const result = await putFile(fileName, fileBuffer, accessType, environmentId);
|
||||
expect(result).toEqual({ success: true, message: "File uploaded" });
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw error when S3 upload fails", async () => {
|
||||
const fileName = "test.jpg";
|
||||
const fileBuffer = Buffer.from("test");
|
||||
const accessType = "private";
|
||||
const environmentId = "env123";
|
||||
|
||||
const error = new Error("Upload failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/web/lib/storage/utils.test.ts
Normal file
42
apps/web/lib/storage/utils.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getFileNameWithIdFromUrl, getOriginalFileNameFromUrl } from "./utils";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Storage Utils", () => {
|
||||
describe("getOriginalFileNameFromUrl", () => {
|
||||
test("should handle URL without file ID", () => {
|
||||
const url = "/storage/test-file.pdf";
|
||||
expect(getOriginalFileNameFromUrl(url)).toBe("test-file.pdf");
|
||||
});
|
||||
|
||||
test("should handle invalid URL", () => {
|
||||
const url = "invalid-url";
|
||||
expect(getOriginalFileNameFromUrl(url)).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFileNameWithIdFromUrl", () => {
|
||||
test("should get full filename with ID from storage URL", () => {
|
||||
const url = "/storage/test-file.pdf--fid--123";
|
||||
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
|
||||
});
|
||||
|
||||
test("should get full filename with ID from external URL", () => {
|
||||
const url = "https://example.com/path/test-file.pdf--fid--123";
|
||||
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
|
||||
});
|
||||
|
||||
test("should handle invalid URL", () => {
|
||||
const url = "invalid-url";
|
||||
expect(getFileNameWithIdFromUrl(url)).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { selectSurvey } from "../../service";
|
||||
import { selectSurvey } from "../service";
|
||||
|
||||
const selectContact = {
|
||||
id: true,
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { surveyCache } from "./cache";
|
||||
import { getSurvey } from "./service";
|
||||
|
||||
export const canUserAccessSurvey = (userId: string, surveyId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [userId, ZId]);
|
||||
|
||||
if (!userId) return false;
|
||||
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) throw new Error("Survey not found");
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`canUserAccessSurvey-${userId}-${surveyId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)();
|
||||
122
apps/web/lib/survey/cache.test.ts
Normal file
122
apps/web/lib/survey/cache.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { surveyCache } from "./cache";
|
||||
|
||||
// Mock the revalidateTag function from next/cache
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidateTag: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("surveyCache", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("tag methods", () => {
|
||||
test("byId returns the correct tag string", () => {
|
||||
const id = "survey-123";
|
||||
expect(surveyCache.tag.byId(id)).toBe(`surveys-${id}`);
|
||||
});
|
||||
|
||||
test("byEnvironmentId returns the correct tag string", () => {
|
||||
const environmentId = "env-456";
|
||||
expect(surveyCache.tag.byEnvironmentId(environmentId)).toBe(`environments-${environmentId}-surveys`);
|
||||
});
|
||||
|
||||
test("byAttributeClassId returns the correct tag string", () => {
|
||||
const attributeClassId = "attr-789";
|
||||
expect(surveyCache.tag.byAttributeClassId(attributeClassId)).toBe(
|
||||
`attributeFilters-${attributeClassId}-surveys`
|
||||
);
|
||||
});
|
||||
|
||||
test("byActionClassId returns the correct tag string", () => {
|
||||
const actionClassId = "action-012";
|
||||
expect(surveyCache.tag.byActionClassId(actionClassId)).toBe(`actionClasses-${actionClassId}-surveys`);
|
||||
});
|
||||
|
||||
test("bySegmentId returns the correct tag string", () => {
|
||||
const segmentId = "segment-345";
|
||||
expect(surveyCache.tag.bySegmentId(segmentId)).toBe(`segments-${segmentId}-surveys`);
|
||||
});
|
||||
|
||||
test("byResultShareKey returns the correct tag string", () => {
|
||||
const resultShareKey = "share-678";
|
||||
expect(surveyCache.tag.byResultShareKey(resultShareKey)).toBe(`surveys-resultShare-${resultShareKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revalidate method", () => {
|
||||
test("calls revalidateTag with correct tag when id is provided", () => {
|
||||
const id = "survey-123";
|
||||
surveyCache.revalidate({ id });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${id}`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag with correct tag when attributeClassId is provided", () => {
|
||||
const attributeClassId = "attr-789";
|
||||
surveyCache.revalidate({ attributeClassId });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`attributeFilters-${attributeClassId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag with correct tag when actionClassId is provided", () => {
|
||||
const actionClassId = "action-012";
|
||||
surveyCache.revalidate({ actionClassId });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${actionClassId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag with correct tag when environmentId is provided", () => {
|
||||
const environmentId = "env-456";
|
||||
surveyCache.revalidate({ environmentId });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${environmentId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag with correct tag when segmentId is provided", () => {
|
||||
const segmentId = "segment-345";
|
||||
surveyCache.revalidate({ segmentId });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${segmentId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag with correct tag when resultShareKey is provided", () => {
|
||||
const resultShareKey = "share-678";
|
||||
surveyCache.revalidate({ resultShareKey });
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${resultShareKey}`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls revalidateTag multiple times when multiple parameters are provided", () => {
|
||||
const props = {
|
||||
id: "survey-123",
|
||||
environmentId: "env-456",
|
||||
attributeClassId: "attr-789",
|
||||
actionClassId: "action-012",
|
||||
segmentId: "segment-345",
|
||||
resultShareKey: "share-678",
|
||||
};
|
||||
|
||||
surveyCache.revalidate(props);
|
||||
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(6);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${props.id}`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${props.environmentId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(
|
||||
`attributeFilters-${props.attributeClassId}-surveys`
|
||||
);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${props.actionClassId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${props.segmentId}-surveys`);
|
||||
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${props.resultShareKey}`);
|
||||
});
|
||||
|
||||
test("does not call revalidateTag when no parameters are provided", () => {
|
||||
surveyCache.revalidate({});
|
||||
expect(vi.mocked(revalidateTag)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
1037
apps/web/lib/survey/service.test.ts
Normal file
1037
apps/web/lib/survey/service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user