revert: remove the onboarding profile step (#12818) (#12838)

This commit is contained in:
Alejandro Bailo
2026-09-17 18:01:41 +02:00
committed by GitHub
parent 61d13f078c
commit 5fe1a6713b
23 changed files with 23 additions and 1677 deletions
-200
View File
@@ -1,200 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import {
isOnboardingProfileRecorded,
skipOnboardingProfile,
submitOnboardingProfile,
} from "./profile";
const ANSWERS = {
declared_cloud_accounts: "11-50",
declared_role: "security",
declared_seniority: "director",
} as const;
describe("onboarding profile actions", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
getAuthHeadersMock.mockReset().mockResolvedValue({});
handleApiErrorMock.mockReset();
handleApiResponseMock.mockReset().mockResolvedValue({ data: { id: "p1" } });
});
it("reports a stored profile and sends the declared buckets", async () => {
// Given
fetchMock.mockResolvedValue(new Response("{}", { status: 201 }));
// When
const result = await submitOnboardingProfile(ANSWERS);
// Then
expect(result).toEqual({ stored: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/onboarding-profiles");
expect(JSON.parse(init.body).data).toEqual({
type: "onboarding-profiles",
attributes: ANSWERS,
});
});
it("records a skip as its own payload", async () => {
// Given
fetchMock.mockResolvedValue(new Response("{}", { status: 201 }));
// When
const result = await skipOnboardingProfile();
// Then
expect(result).toEqual({ stored: true });
expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toEqual(
{
skipped: true,
},
);
});
it("treats a transport failure as not stored", async () => {
// Given — the request never reaches the API.
fetchMock.mockRejectedValue(new Error("network down"));
// When
const result = await submitOnboardingProfile(ANSWERS);
// Then
expect(result.stored).toBe(false);
expect(result.error).toBeTruthy();
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
});
it("treats a rejection without an errors array as not stored", async () => {
// Given — e.g. a 403, which `handleApiResponse` returns as a bare error.
fetchMock.mockResolvedValue(new Response("{}", { status: 403 }));
handleApiResponseMock.mockResolvedValue({
error: "Forbidden",
status: 403,
});
// When
const result = await submitOnboardingProfile(ANSWERS);
// Then
expect(result).toEqual({ stored: false, error: "Forbidden" });
});
it("surfaces the API's own message when the payload carries one", async () => {
// Given
fetchMock.mockResolvedValue(new Response("{}", { status: 400 }));
handleApiResponseMock.mockResolvedValue({
error: "Bad request",
errors: [{ detail: "This field is required." }],
});
// When
const result = await submitOnboardingProfile(ANSWERS);
// Then
expect(result).toEqual({
stored: false,
error: "This field is required.",
});
});
it("treats a thrown server error as not stored", async () => {
// Given — `handleApiResponse` throws on 5xx.
fetchMock.mockResolvedValue(new Response("{}", { status: 500 }));
handleApiResponseMock.mockRejectedValue(new Error("server error"));
// When
const result = await skipOnboardingProfile();
// Then
expect(result.stored).toBe(false);
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
});
it.each([
["a submission", () => submitOnboardingProfile(ANSWERS)],
["a skip", () => skipOnboardingProfile()],
])(
"reports %s as unstored when the session cannot be read",
async (_, run) => {
// Given — `auth()` rejects, e.g. a session this deployment cannot decode.
getAuthHeadersMock.mockRejectedValue(new Error("session unreadable"));
// When
const result = await run();
// Then — a result, never a throw: the step stays eligible next login.
expect(result).toEqual({ stored: false, error: expect.any(String) });
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
},
);
it("reads an unreadable session as an unknown profile state", async () => {
// Given — this runs in the root layout, so a throw would abort its render.
getAuthHeadersMock.mockRejectedValue(new Error("session unreadable"));
// When / Then — `undefined` makes the gate fail open.
await expect(isOnboardingProfileRecorded()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses answers outside the declared vocabulary", async () => {
// When / Then — validation happens before any request.
await expect(
submitOnboardingProfile({
...ANSWERS,
declared_role: "ceo",
} as unknown as typeof ANSWERS),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
["an empty list", { data: [] }, false],
["a stored row", { data: [{ id: "p1" }] }, true],
])("reads %s as recorded=%s", async (_, payload, expected) => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify(payload), { status: 200 }),
);
// Then
expect(await isOnboardingProfileRecorded()).toBe(expected);
});
it("returns undefined when the read fails, so the gate fails open", async () => {
// Given
fetchMock.mockResolvedValue(new Response("{}", { status: 500 }));
// Then
expect(await isOnboardingProfileRecorded()).toBeUndefined();
});
});
-119
View File
@@ -1,119 +0,0 @@
"use server";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
DECLARED_CLOUD_ACCOUNTS,
DECLARED_ROLE,
DECLARED_SENIORITY,
type OnboardingProfileAnswers,
} from "@/types/onboarding-profile";
const ONBOARDING_PROFILES_PATH = "/onboarding-profiles";
const RESOURCE_TYPE = "onboarding-profiles";
// Explicit outcome instead of the raw API payload: the caller records the
// step as resolved only when the row was really written, and a transport
// failure is as unsuccessful as a rejected payload.
export interface OnboardingProfileResult {
stored: boolean;
error?: string;
}
const onboardingProfileAnswersSchema = z.object({
declared_cloud_accounts: z.enum(
Object.values(DECLARED_CLOUD_ACCOUNTS) as [string, ...string[]],
),
declared_role: z.enum(Object.values(DECLARED_ROLE) as [string, ...string[]]),
declared_seniority: z.enum(
Object.values(DECLARED_SENIORITY) as [string, ...string[]],
),
});
const GENERIC_FAILURE = "The onboarding profile could not be saved.";
const failureMessage = (payload: unknown): string | undefined => {
if (typeof payload !== "object" || payload === null) return undefined;
const result = payload as { error?: unknown; errors?: unknown };
if (Array.isArray(result.errors)) {
const detail = (result.errors[0] as { detail?: unknown })?.detail;
if (typeof detail === "string") return detail;
}
return typeof result.error === "string" ? result.error : undefined;
};
const postOnboardingProfile = async (
attributes: Record<string, unknown>,
): Promise<OnboardingProfileResult> => {
const body = JSON.stringify({
data: { type: RESOURCE_TYPE, attributes },
});
let response: Response;
try {
// Inside the boundary: `getAuthHeaders` awaits `auth()`, which rejects on
// an undecodable session. The contract above promises a result, not a
// throw, so a dead session must read as "not stored" and leave the step
// eligible for the next login.
const headers = await getAuthHeaders({ contentType: true });
response = await fetch(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`, {
method: "POST",
headers,
body,
});
} catch (error) {
handleApiError(error);
return { stored: false, error: GENERIC_FAILURE };
}
// `handleApiResponse` reports to Sentry and throws on server errors; the
// status is what decides the outcome, since a rejection can come back
// without an `errors` array.
try {
const payload = await handleApiResponse(response);
if (!response.ok) {
return {
stored: false,
error: failureMessage(payload) ?? GENERIC_FAILURE,
};
}
return { stored: true };
} catch (error) {
handleApiError(error);
return { stored: false, error: GENERIC_FAILURE };
}
};
// Records the three declared buckets. The API keeps the first answer per
// tenant: a repeated submission answers 200 with the stored profile.
export const submitOnboardingProfile = async (
answers: OnboardingProfileAnswers,
): Promise<OnboardingProfileResult> =>
postOnboardingProfile(onboardingProfileAnswersSchema.parse(answers));
// A skip is a fact worth storing: it separates "declined" from "never
// shown" in the funnel.
export const skipOnboardingProfile =
async (): Promise<OnboardingProfileResult> =>
postOnboardingProfile({ skipped: true });
// Whether the tenant already went through the step on any device. `undefined`
// means the read failed; the gate fails open and does not force the modal.
export const isOnboardingProfileRecorded = async (): Promise<
boolean | undefined
> => {
const url = new URL(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`);
url.searchParams.set("page[size]", "1");
try {
const headers = await getAuthHeaders({ contentType: false });
const response = await fetch(url.toString(), { headers });
if (!response.ok) return undefined;
const payload = await response.json();
return Array.isArray(payload?.data) ? payload.data.length > 0 : undefined;
} catch {
return undefined;
}
};