Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,39 @@ Fixes #(issue)
Loom Video: https://www.loom.com/
-->

## How should this be tested?
## QA / Test Plan

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
<!--
This section is the source of truth for release QA — QA checklists are generated from it. Describe the
PR's ACTUAL behavior, not the commit titles. Keep steps click-by-click and grounded in real behavior.
Omit a subsection only when it genuinely does not apply. Flag any inferred specifics (thresholds, rate
limits, timeouts) for human verification.
-->

**How to test** <!-- happy path first, then edge cases (boundaries, negative input, permissions) -->

- [ ] Step → expected result (include route, error code, and any env flag/var)
- [ ] Edge case → expected result

**Preconditions / test data** <!-- accounts, seed data, feature flags, plan/entitlement, Cloud vs self-host -->

-

**Risks & regressions** <!-- what nearby behavior could break; what to re-check -->

-

**Migrations / env / cutover** <!-- new/changed env vars (diff .env.example), DB migrations, cutover steps; write "none" if none -->

- Test A
- Test B
- none

## Checklist

<!-- We're starting to get more and more contributions. Please help us making this efficient for all of us and go through this checklist. Please tick off what you did -->

### Required

- [ ] Filled out the "How to test" section in this PR
- [ ] Filled out the "QA / Test Plan" section in this PR (behavior, edge cases, preconditions, risks, migrations/env)
- [ ] Read [How we Code at Formbricks](<[https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md](https://formbricks.com/docs/contributing/how-we-code)>)
- [ ] Self-reviewed my own code
- [ ] Commented on my code in hard-to-understand bits
Expand Down
42 changes: 31 additions & 11 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ on:
# Add other secrets if necessary
workflow_dispatch:

env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}

permissions:
contents: read
actions: read
Expand Down Expand Up @@ -56,13 +52,14 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout

- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0

- name: Setup Node.js (version from .nvmrc)
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: ".nvmrc"

- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
Expand Down Expand Up @@ -124,9 +121,35 @@ jobs:
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh

- name: Build App
# NOTE: no build cache here. We measured caching Next's .next/cache
# incremental compiler cache too — warm build (4m17s) was no faster than
# cold (3m46s), because the Turbo-orchestrated `pnpm build` doesn't reuse
# it in this setup (and there's no Turbo remote cache). Build is ~4min
# regardless; a real reduction needs a Turbo cache backend, not local
# actions/cache.

# Cache the downloaded browser binaries so re-runs skip the ~100MB
# Chromium download. `--with-deps` below still runs the (uncacheable) apt
# step, but a cache hit avoids the browser fetch itself.
- name: Cache Playwright browsers
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-

- name: Build App (with Playwright browser install in parallel)
run: |
set -euo pipefail
# Install Playwright browsers + OS deps concurrently with the Next.js
# build. Both are largely I/O-bound, so overlapping them shaves the
# browser-install time (~1-2 min) off the critical path. `wait`
# surfaces a non-zero exit from either process.
pnpm exec playwright install --with-deps &
PW_INSTALL_PID=$!
pnpm build --filter=@formbricks/web...
wait "$PW_INSTALL_PID"

- name: Apply Prisma Migrations
run: |
Expand Down Expand Up @@ -178,9 +201,6 @@ jobs:
sleep 10
done

- name: Install Playwright
run: pnpm exec playwright install --with-deps

- name: Determine Playwright execution mode
shell: bash
env:
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Heuristic:

Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.

Every PR must use `.github/pull_request_template.md` — including its `## QA / Test Plan` section, which is the source of truth for release QA (follow the guidance inline in the template; keep its subsection headings). Agents fill it on PR open and re-update it in the same turn on every PR change (new commits, scope or review fixes) so it never drifts from the diff — treat a stale QA section as a bug.

## Next.js Documentation

Do not rely on training data for Next.js behavior in this repo. For any Next.js-related work (routing, layouts, server/client components, caching, next.config, etc.), use the `nextjs-docs` skill, which indexes the version-pinned local docs in `.next-docs/`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});

test("should throw InvalidInputError when survey does not exist (RelatedRecordDoesNotExist)", async () => {
test("should throw InvalidInputError when survey does not exist (RecordNotFound)", async () => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);

Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/api/v1/management/responses/lib/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,9 @@ describe("Response Lib Tests", () => {
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});

test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
test("should handle RecordNotFound error with specific message", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
code: "P2025", // PrismaErrorType.RecordNotFound
clientVersion: "2.0",
});
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(mockOrganization);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/management/responses/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
if (error.code === PrismaErrorType.RecordNotFound) {
throw new DatabaseError("Display ID does not exist");
}
throw new DatabaseError(error.message);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const deleteWebhook = async (id: string): Promise<Omit<Webhook, "secret">
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RelatedRecordDoesNotExist
error.code === PrismaErrorType.RecordNotFound
) {
throw new ResourceNotFoundError("Webhook", id);
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3279,6 +3279,7 @@ checksums:
workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
workspace/surveys/edit/this_will_remove_the_language_from_this_survey: 65cfdf7f8ce806cc4f1a5a34466de080
workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
Expand Down
11 changes: 11 additions & 0 deletions apps/web/lib/actionClass/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError);
});

test("should throw DatabaseError for PrismaClientKnownRequestError", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
vi.mocked(prisma.actionClass.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(deleteActionClass("id4")).rejects.toThrow(DatabaseError);
});

test("should rethrow unknown errors", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
const error = new Error("unknown");
Expand Down
106 changes: 103 additions & 3 deletions apps/web/lib/feedback-source/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbr
import {
createFeedbackSourceWithMappings,
deleteFeedbackSource,
getFeedbackSourceWithMappingsById,
getFeedbackSourcesBySurveyId,
getFeedbackSourcesWithMappings,
updateFeedbackSource,
Expand All @@ -15,6 +16,7 @@ vi.mock("@formbricks/database", () => ({
prisma: {
feedbackSource: {
findMany: vi.fn(),
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
create: vi.fn(),
update: vi.fn(),
Expand Down Expand Up @@ -156,6 +158,59 @@ describe("getFeedbackSourcesWithMappings", () => {

await expect(getFeedbackSourcesWithMappings(ENV_ID)).rejects.toThrow(DatabaseError);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.feedbackSource.findMany).mockRejectedValue(new Error("boom"));

await expect(getFeedbackSourcesWithMappings(ENV_ID)).rejects.toThrow("boom");
});
});

describe("getFeedbackSourceWithMappingsById", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("returns the feedbackSource when found", async () => {
vi.mocked(prisma.feedbackSource.findUnique).mockResolvedValue(
mockFeedbackSourceWithMappingsFromDb as never
);

const result = await getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID);

expect(prisma.feedbackSource.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: FEEDBACK_SOURCE_ID, workspaceId: ENV_ID },
})
);
expect(result).toEqual(mockFeedbackSourceWithMappings);
});

test("returns null when not found", async () => {
vi.mocked(prisma.feedbackSource.findUnique).mockResolvedValue(null as never);

const result = await getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID);
expect(result).toBeNull();
});

test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.feedbackSource.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P1001",
clientVersion: "5.0.0",
})
);

await expect(getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow(
DatabaseError
);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.feedbackSource.findUnique).mockRejectedValue(new Error("boom"));

await expect(getFeedbackSourceWithMappingsById(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow("boom");
});
});

describe("getFeedbackSourcesBySurveyId", () => {
Expand Down Expand Up @@ -199,6 +254,12 @@ describe("getFeedbackSourcesBySurveyId", () => {

await expect(getFeedbackSourcesBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.feedbackSource.findMany).mockRejectedValue(new Error("boom"));

await expect(getFeedbackSourcesBySurveyId(SURVEY_ID)).rejects.toThrow("boom");
});
});

describe("updateFeedbackSource", () => {
Expand Down Expand Up @@ -232,7 +293,7 @@ describe("updateFeedbackSource", () => {
test("throws ResourceNotFoundError when feedbackSource does not exist", async () => {
vi.mocked(prisma.feedbackSource.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
code: "P2025",
clientVersion: "5.0.0",
})
);
Expand Down Expand Up @@ -285,7 +346,7 @@ describe("deleteFeedbackSource", () => {
test("throws ResourceNotFoundError when feedbackSource does not exist", async () => {
vi.mocked(prisma.feedbackSource.delete).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
code: "P2025",
clientVersion: "5.0.0",
})
);
Expand All @@ -303,6 +364,12 @@ describe("deleteFeedbackSource", () => {

await expect(deleteFeedbackSource(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow(DatabaseError);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.feedbackSource.delete).mockRejectedValue(new Error("boom"));

await expect(deleteFeedbackSource(FEEDBACK_SOURCE_ID, ENV_ID)).rejects.toThrow("boom");
});
});

describe("createFeedbackSourceWithMappings", () => {
Expand Down Expand Up @@ -491,6 +558,23 @@ describe("createFeedbackSourceWithMappings", () => {
).rejects.toThrow(new InvalidInputError("FEEDBACK_SOURCE_FIELD_MAPPING_DUPLICATE"));
});

test("throws FEEDBACK_SOURCE_NAME_DUPLICATE on a unique violation without target meta", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
})
);

await expect(
createFeedbackSourceWithMappings(ENV_ID, {
name: "Dup",
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("FEEDBACK_SOURCE_NAME_DUPLICATE"));
});

test("throws FEEDBACK_SOURCE_DIRECTORY_NOT_ASSIGNED_TO_WORKSPACE on composite FK violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
makeForeignKeyError("FeedbackSource_feedbackDirectoryId_workspaceId_fkey")
Expand Down Expand Up @@ -551,6 +635,14 @@ describe("createFeedbackSourceWithMappings", () => {
createFeedbackSourceWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackDirectoryId: FRD_ID })
).rejects.toThrow(DatabaseError);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(new Error("boom"));

await expect(
createFeedbackSourceWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackDirectoryId: FRD_ID })
).rejects.toThrow("boom");
});
});

describe("updateFeedbackSourceWithMappings", () => {
Expand Down Expand Up @@ -652,7 +744,7 @@ describe("updateFeedbackSourceWithMappings", () => {
test("throws ResourceNotFoundError when feedbackSource does not exist", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2015",
code: "P2025",
clientVersion: "5.0.0",
})
);
Expand Down Expand Up @@ -732,4 +824,12 @@ describe("updateFeedbackSourceWithMappings", () => {
DatabaseError
);
});

test("rethrows non-Prisma errors", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(new Error("boom"));

await expect(updateFeedbackSourceWithMappings(FEEDBACK_SOURCE_ID, ENV_ID, { name: "x" })).rejects.toThrow(
"boom"
);
});
});
Loading
Loading