feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user