Skip to main content

GForms Add-on Tests

The GForms add-on spans three tiers. There is no Tier 3 coverage because the add-on has no service-layer code of its own — it delegates DB access entirely to existing services already covered by their own Tier 3 tests.

src/lib/gforms/generateClause.ts → Tier 1
src/lib/gforms/googleFormsApi.ts → Tier 1 (extractFormId) + Tier 2 (addItemsToGoogleForm)
src/lib/gforms/resolveGformsUser.ts → Tier 2
src/app/api/gforms/activities/route.ts → Tier 2
src/app/api/gforms/clause/route.ts → Tier 2
src/lib/ui/gforms/GFormsForm.tsx → Tier 4
src/lib/ui/gforms/ClauseOutput.tsx → Tier 4
src/app/[locale]/gforms/page.tsx → not tested (see below)

src/app/[locale]/gforms/page.tsx is not tested directly. It is a three-line RSC shell: auth check → resolveGformsUser → pass props to <GFormsForm>. All meaningful paths are covered by the route handler and resolveGformsUser tests.


Tier 1 — generateClause.test.ts

generateClause is fully pure: questionnaire answers + activity object + translations → clause output. No imports that touch APIs, DB, or DOM.

No-clause rule

The key invariant: anonymous === "yes" AND indirect !== "yes"needsClause: false. The indirect field is only evaluated when anonymous === "yes"; when anonymous === "no" the function always proceeds to clause generation regardless of indirect.

anonymousindirectneedsClause
"yes""no"false
"yes"undefined (omitted)false
"yes""yes"true
"no""yes"true
"no""no"true
"no"undefinedtrue

When needsClause: true, each category key set to "yes" pushes the corresponding translation string into consentItems. Test each key in isolation:

KeyTranslation field
"1ethnics"t.consentEthnics
"2opinion"t.consentOpinion
"3belief"t.consentBelief
"4biogen"t.consentBiogen
"5health"t.consentHealth
"6sex"t.consentSex
"7international"t.consentInternational
"8communication"t.consentCommunication

These are separate concerns in the output. "No special categories selected" is a valid and important case — consentItems is empty but needsClause is still true and infoClause is still generated. Test this explicitly:

ScenarioconsentItemsinfoClause
anonymous=no, no categories[]populated
anonymous=no, 3 categories3 itemspopulated

{url} substitution

// t.infoClauseText = "See {url} for details."
// activity.publicUrl = "https://org.rat.gd/en/v/42"
// → infoClause = "See https://org.rat.gd/en/v/42 for details."

Tier 1 — googleFormsApi.test.ts (extractFormId)

extractFormId is a pure regex helper. No mocking needed.

InputExpected output
https://docs.google.com/forms/d/FORM_ID/editFORM_ID
https://docs.google.com/forms/d/e/FORM_ID/viewformFORM_ID
FORM_ID (raw ID)FORM_ID
FORM_ID (whitespace)FORM_ID

Tier 2 — googleFormsApi.test.ts (addItemsToGoogleForm)

addItemsToGoogleForm calls global.fetch. Mock fetch with vi.stubGlobal or vi.spyOn(global, "fetch").

// @vitest-environment node
import { vi, describe, it, expect, beforeEach } from "vitest";
import { addItemsToGoogleForm } from "./googleFormsApi";

beforeEach(() => vi.restoreAllMocks());
ScenarioMockExpected
fetch ok{ ok: true }{ success: true }
fetch not ok, JSON error{ ok: false, json: () => { error: { message: "Quota exceeded" } } }{ success: false, message: "Quota exceeded" }
fetch not ok, non-JSON body{ ok: false, json: () => Promise.reject() }{ success: false, message: "HTTP 403" }
batchUpdate URLany ok responseURL contains encodeURIComponent(formId)
Multiple itemsany ok responserequests array length equals items.length

Tier 2 — resolveGformsUser.test.ts

Mocks: @clerk/nextjs/server (both auth and clerkClient), @/services (explicit factory — use vi.mock("@/services", () => ({ ... })) to avoid the auto-mock cascade into Mongoose models described in the Tier 2 guide).

// @vitest-environment node
import { vi, describe, it, expect, beforeEach } from "vitest";

vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn(),
clerkClient: vi.fn(),
}));

vi.mock("@/services", () => ({
getOrganizationByClerkOrganizationId: vi.fn(),
getOrganizationByShortNameRO: vi.fn(),
getRopaByIdRO: vi.fn(),
}));

resolveGformsUser

ScenarioExpected
Clerk session has userIdUses session; does not call getUserList
No session, emailParam given, user foundUses that userId
No session, emailParam given, user not found{ error: "No Clerk user found for this email", status: 401 }
No session, no emailParam{ error: "Unauthorized", status: 401 }
No org memberships{ error: "User has no organizations", status: 403 }
All getOrganizationByClerkOrganizationId calls return isError: true{ error: "No accessible organizations found", status: 403 }
One membership, org found{ orgs: [{ shortName, orgName, clerkOrganizationId, locales }] }
orgName derived from self-entity (ID 0) in partnersCorrect orgName used
No self-entity in partnersFalls back to shortName
locales built from org.ropasCorrect locale array

getActivitiesForOrgLocale

ScenarioExpected
getOrganizationByShortNameRO fails{ error: "Organization not found", status: 404 }
Locale not in org.ropas{ error: "Locale \"xx\" not configured...", status: 400 }
getRopaByIdRO fails{ error: "Failed to fetch ROPA data", status: 500 }
Mix of active and inactive activitiesOnly active ones returned
Activities sorted alphabetically by activityNameCorrect order
NODE_ENV=developmentURL is http://{shortName}.localhost:3000/{locale}/v/{id}
NODE_ENV=productionURL is https://{shortName}.rat.gd/{locale}/v/{id}

For the URL environment tests, set process.env.NODE_ENV before calling the function and restore it in afterEach.


Tier 2 — src/app/api/gforms/activities/route.test.ts

Mock @/lib/gforms/resolveGformsUser (both resolveGformsUser and getActivitiesForOrgLocale). Build a NextRequest with new Request(url) for each test — no need to mock next/server.

vi.mock("@/lib/gforms/resolveGformsUser", () => ({
resolveGformsUser: vi.fn(),
getActivitiesForOrgLocale: vi.fn(),
}));
ScenarioStatusBody field
Missing locale param400error
resolveGformsUser returns errorresolved.statusresolved.error
shortName given but not in user's orgs403error
shortName given and in orgscontinues to activities
No shortName, single orguses that org automatically
No shortName, multiple orgs200{ requiresOrgSelection: true, orgs }
getActivitiesForOrgLocale returns erroractivities.statusactivities.error
Happy path200{ activities, shortName }

Tier 2 — src/app/api/gforms/clause/route.test.ts

Mocks: @/lib/gforms/resolveGformsUser, next-intl/server (getTranslations), @/lib/gforms/generateClause.

vi.mock("@/lib/gforms/resolveGformsUser", () => ({
resolveGformsUser: vi.fn(),
getActivitiesForOrgLocale: vi.fn(),
}));

vi.mock("next-intl/server", () => ({
getTranslations: vi.fn().mockResolvedValue((key: string) => key),
}));

vi.mock("@/lib/gforms/generateClause", () => ({
generateClause: vi.fn(),
}));
ScenarioStatusBody field
Invalid JSON body400error
Missing locale400error
Missing activityId400error
Missing formData400error
resolveGformsUser returns errorpropagated statuserror
shortName not accessible403error
Multiple orgs, no shortName400error
getActivitiesForOrgLocale returns errorpropagated statuserror
activityId (string) not in activities404error
activityId (number) found200generateClause result + activityName
generateClause returns needsClause: false200{ needsClause: false, activityName }

Tier 4 — GFormsForm.test.tsx

Environment: jsdom. Mocks: next-intl, global.fetch.

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

Use vi.stubGlobal("fetch", vi.fn()) to mock API calls. Restore with vi.unstubAllGlobals() in afterEach.

Org / locale selector rendering

ScenarioAssertion
Single orgNo org <Select> rendered
Multiple orgsOrg <Select> rendered with all org names
Single locale on selected orgNo locale <Select> rendered
Multiple localesLocale <Select> rendered

Activity fetch

ScenarioAssertion
On mount with org + localefetch called for /api/gforms/activities?shortName=...&locale=...
Loading stateSpinner (CircularProgress) visible while fetching
fetch resolves with activitiesActivity <Select> populated with returned names
fetch resolves with empty listActivity <Select> has only the default empty item

Questionnaire and submit

ScenarioAssertion
No activity selected"Generate" button (generateButton key) disabled
Activity selectedButton enabled
Submitfetch POST /api/gforms/clause called with { shortName, locale, activityId, formData }
Checked categories included in formDataformData["1ethnics"] === "yes" when checked
Unchecked categories omitted from formDataNo "2opinion" key when unchecked
anonymous=yes shows indirect questionIndirect <RadioGroup> rendered
anonymous=no hides indirect questionIndirect <RadioGroup> not rendered
Successful POST<ClauseOutput> rendered (check for output container)

Tier 4 — ClauseOutput.test.tsx

Environment: jsdom. Mocks: next-intl, navigator.clipboard.

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));

// Before each test that exercises CopyButton:
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
writable: true,
});

needsClause: false

Assertion
Alert with severity "info" rendered
No consent title or consent items in the DOM
No info clause section

needsClause: true — clause display

Assertion
consentTitle text visible
Each item in consentItems rendered as a list item
infoClauseTitle text visible
infoClause text visible

needsClause: true — no categories

| Scenario | Assertion | |---| | consentItems: [] | Consent items section not rendered | | infoClause still present | Info clause section still rendered |

CopyButton

Use vi.useFakeTimers() / vi.useRealTimers() to control the 2-second reset.

| Scenario | Assertion | |---| | Button clicked | navigator.clipboard.writeText called with expected text | | Immediately after click | Check icon (CheckIcon) visible, copy icon hidden | | After 2 000 ms | Copy icon restored, check icon hidden |

"Add to Google Form" section

| Scenario | Assertion | |---| | NEXT_PUBLIC_GOOGLE_CLIENT_ID not set | Form URL TextField not rendered | | NEXT_PUBLIC_GOOGLE_CLIENT_ID set | Form URL TextField rendered |

To test the env-variable branch, set process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID before importing the component and restore it in afterEach. Because the value is read at module scope (const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ""), use vi.resetModules() + dynamic import() to pick up the changed value.


What is not covered

ScopeReason
Apps Script files (GForms/*.gs, Clausules_RGPD.html)Not part of the Next.js app; no test runner configured for .gs
ClauseOutput Google OAuth popup (window.google.accounts.oauth2)Third-party client SDK; requires a real browser. The underlying addItemsToGoogleForm call is covered at Tier 2
src/app/[locale]/gforms/page.tsxThree-line RSC shell; no branching logic beyond auth redirect, which is an auth-layer concern
Multi-org Apps Script flowKnown unimplemented gap documented in GForms/README.md; no code to test yet