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.
anonymous | indirect | needsClause |
|---|---|---|
"yes" | "no" | false |
"yes" | undefined (omitted) | false |
"yes" | "yes" | true |
"no" | "yes" | true |
"no" | "no" | true |
"no" | undefined | true |
Category → consent item mapping
When needsClause: true, each category key set to "yes" pushes the corresponding
translation string into consentItems. Test each key in isolation:
| Key | Translation 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 |
Consent items vs. info clause independence
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:
| Scenario | consentItems | infoClause |
|---|---|---|
anonymous=no, no categories | [] | populated |
anonymous=no, 3 categories | 3 items | populated |
{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.
| Input | Expected output |
|---|---|
https://docs.google.com/forms/d/FORM_ID/edit | FORM_ID |
https://docs.google.com/forms/d/e/FORM_ID/viewform | FORM_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());
| Scenario | Mock | Expected |
|---|---|---|
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 URL | any ok response | URL contains encodeURIComponent(formId) |
| Multiple items | any ok response | requests 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
| Scenario | Expected |
|---|---|
Clerk session has userId | Uses session; does not call getUserList |
No session, emailParam given, user found | Uses 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 partners | Correct orgName used |
No self-entity in partners | Falls back to shortName |
locales built from org.ropas | Correct locale array |
getActivitiesForOrgLocale
| Scenario | Expected |
|---|---|
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 activities | Only active ones returned |
Activities sorted alphabetically by activityName | Correct order |
NODE_ENV=development | URL is http://{shortName}.localhost:3000/{locale}/v/{id} |
NODE_ENV=production | URL 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(),
}));
| Scenario | Status | Body field |
|---|---|---|
Missing locale param | 400 | error |
resolveGformsUser returns error | resolved.status | resolved.error |
shortName given but not in user's orgs | 403 | error |
shortName given and in orgs | — | continues to activities |
No shortName, single org | — | uses that org automatically |
No shortName, multiple orgs | 200 | { requiresOrgSelection: true, orgs } |
getActivitiesForOrgLocale returns error | activities.status | activities.error |
| Happy path | 200 | { 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(),
}));
| Scenario | Status | Body field |
|---|---|---|
| Invalid JSON body | 400 | error |
Missing locale | 400 | error |
Missing activityId | 400 | error |
Missing formData | 400 | error |
resolveGformsUser returns error | propagated status | error |
shortName not accessible | 403 | error |
Multiple orgs, no shortName | 400 | error |
getActivitiesForOrgLocale returns error | propagated status | error |
activityId (string) not in activities | 404 | error |
activityId (number) found | 200 | generateClause result + activityName |
generateClause returns needsClause: false | 200 | { 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
| Scenario | Assertion |
|---|---|
| Single org | No org <Select> rendered |
| Multiple orgs | Org <Select> rendered with all org names |
| Single locale on selected org | No locale <Select> rendered |
| Multiple locales | Locale <Select> rendered |
Activity fetch
| Scenario | Assertion |
|---|---|
| On mount with org + locale | fetch called for /api/gforms/activities?shortName=...&locale=... |
| Loading state | Spinner (CircularProgress) visible while fetching |
fetch resolves with activities | Activity <Select> populated with returned names |
fetch resolves with empty list | Activity <Select> has only the default empty item |
Questionnaire and submit
| Scenario | Assertion |
|---|---|
| No activity selected | "Generate" button (generateButton key) disabled |
| Activity selected | Button enabled |
| Submit | fetch POST /api/gforms/clause called with { shortName, locale, activityId, formData } |
Checked categories included in formData | formData["1ethnics"] === "yes" when checked |
Unchecked categories omitted from formData | No "2opinion" key when unchecked |
anonymous=yes shows indirect question | Indirect <RadioGroup> rendered |
anonymous=no hides indirect question | Indirect <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
| Scope | Reason |
|---|---|
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.tsx | Three-line RSC shell; no branching logic beyond auth redirect, which is an auth-layer concern |
| Multi-org Apps Script flow | Known unimplemented gap documented in GForms/README.md; no code to test yet |