feat(org): org creation wizard + app profile (v2 phase A1-2)

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* §v2-A1.2 — the 5-step organization-creation wizard (frontend §2.7).
*
* The wizard is the only path that creates an org + single app, so the tests
* focus on what would silently corrupt the product: the catalog loading, step
* navigation, and — critically — that a typed git/deploy secret is never
* written into the resumable draft payload (only the final submit carries it).
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: { id: "u1", email: "m@e.com", platform_role: "manager" } as Record<string, unknown>,
organizations: [] as Array<Record<string, unknown>>,
activeOrganization: null as Record<string, unknown> | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
setupApi: {
getCatalog: vi.fn(),
getDraft: vi.fn(),
saveDraft: vi.fn((_step: number, _data: Record<string, unknown>) => Promise.resolve()),
checkGit: vi.fn(async () => ({ ok: true })),
checkDeploy: vi.fn(async () => ({ ok: true })),
complete: vi.fn(async () => ({
organization: { id: "org-1", name: "Acme", slug: "acme" },
project: { id: "p-1", name: "app", slug: "app" },
deploy_target_id: null,
})),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace }),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
const replace = vi.fn();
Object.assign(mocks, { replace });
import OnboardingPage from "@/app/onboarding/page";
const CATALOG = {
app_types: ["web_app"],
app_type_labels: { web_app: "وب‌اپ" },
git_sources: ["platform", "external"],
languages: [
{
key: "node",
display: "Node.js",
frameworks: [
{
key: "next",
display: "Next.js",
app_type: "web_app",
defaults: {
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: 3000,
healthcheck_path: "/",
docker_image: "node:20",
},
},
],
},
],
};
beforeEach(() => {
vi.clearAllMocks();
mocks.session.ready = true;
mocks.session.user = { id: "u1", email: "m@e.com", platform_role: "manager" };
mocks.session.organizations = [];
mocks.session.activeOrganization = null;
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
mocks.setupApi.getDraft.mockResolvedValue({ step: 1, data: {} });
});
describe("the organization wizard", () => {
it("loads the catalog and opens on step 1 (سازمان)", async () => {
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("نام سازمان")).toBeInTheDocument());
expect(mocks.setupApi.getCatalog).toHaveBeenCalledTimes(1);
});
it("auto-suggests a slug from the org name", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme Corp" } });
const slug = screen.getByLabelText("نامک (slug)") as HTMLInputElement;
expect(slug.value).toBe("acme-corp");
});
it("moves to step 2 (اپلیکیشن) on next", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme" } });
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() => expect(screen.getByText("نوع اپلیکیشن")).toBeInTheDocument());
});
it("never writes the git secret into the draft payload", async () => {
// Resume at step 3 (git) with an external repo but no secret saved yet.
mocks.setupApi.getDraft.mockResolvedValue({
step: 3,
data: { git_source: "external", external_repo_url: "https://example.com/x.git" },
});
render(<OnboardingPage />);
const secret = await screen.findByLabelText("توکن / رمز");
fireEvent.change(secret, { target: { value: "supersecret-token-123" } });
// Advance to the production step — this triggers a draft auto-save.
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() =>
expect(mocks.setupApi.saveDraft).toHaveBeenCalled(),
);
const lastCall = mocks.setupApi.saveDraft.mock.calls.at(-1)!;
const payload = lastCall[1] as Record<string, unknown>;
// The draft carries the non-secret repo url...
expect(payload).toMatchObject({ external_repo_url: "https://example.com/x.git" });
// ...but never the secret itself.
expect(payload).not.toHaveProperty("external_git_secret");
expect(JSON.stringify(payload)).not.toContain("supersecret-token-123");
});
it("bounces a manager who already owns an organization to the dashboard", async () => {
mocks.session.organizations = [{ id: "org-x", name: "X", slug: "x" }];
render(<OnboardingPage />);
await waitFor(() => expect(replace).toHaveBeenCalledWith("/dashboard"));
});
it("shows the waiting screen for a plain user who cannot create orgs", async () => {
mocks.session.user = { id: "u2", email: "p@e.com", platform_role: "user" };
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("در انتظار دعوت")).toBeInTheDocument());
});
});