fix(onboarding): trocar de organização não configurada deixava a pessoa presa

Duas coisas, e a segunda é o defeito que a primeira expôs.

1. Dois seeds disputavam o slug `e2e-segunda-org`: o de funis cria a org
   SEM `onboarded_at`, e o de duas organizações, ao encontrá-la, devolvia
   o id sem olhar o estado. Na parte 2 do e2e o de funis roda antes, e a
   org B chegava sem onboarding — `app/app/layout.tsx:51` manda essa org
   para /onboarding, o shell sai da árvore e o seletor vai junto. Era o
   `element(s) not found` que derrubou a main. Slug próprio remove a
   disputa; o seed também garante a precondição que ele exige.

2. E o wizard não tinha saída. Medido no snapshot do run 33164258175:
   "Termos de Uso", "Política de Privacidade" e um "Continuar"
   desabilitado — nenhuma porta. Quem foi convidado para uma organização
   nova e trocou para ver perdia o caminho de volta. Agora o cabeçalho
   oferece a volta para quem tem outra organização, e navega de verdade
   (setActiveOrg revalida /app, não /onboarding).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKUXpCeT28H7yPMfvEqJc6
This commit is contained in:
Rafael Melgaço
2026-08-28 08:18:28 -03:00
co-authored by Claude Opus 5
parent 8b868bf3e4
commit 036db8e68b
8 changed files with 397 additions and 3 deletions
+1
View File
@@ -250,6 +250,7 @@ jobs:
agenda-painel-cabe-na-tela.spec.ts
agenda-ver-na-agenda.spec.ts
agenda-escopo-da-organizacao.spec.ts
troca-de-organizacao-tem-volta.spec.ts
agenda-caminho-ate-os-horarios.spec.ts
agenda-google-volta-do-consentimento.spec.ts
# Cada item aqui tem o motivo MEDIDO, não presumido — e o gate cobra que a
@@ -0,0 +1,108 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { setActiveOrg } from "@/app/actions/shell/setActiveOrg";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ArrowBendUpLeft, CaretDown } from "@/lib/ui/icons";
/**
* A VOLTA — o caminho de saída do wizard para quem tem outra organização.
*
* ═══ O defeito que isto fecha ═══
*
* `app/app/layout.tsx` manda para `/onboarding` toda organização ativa sem
* `onboarded_at`. Trocar para uma organização recém-criada — pelo seletor no
* topo, um clique — levava a pessoa para o wizard dela **e tirava o seletor da
* tela junto**: o layout de `/app` sai inteiro da árvore, e com ele o
* `TenantSwitcher`.
*
* O que sobrava no wizard, medido no snapshot de uma falha do CI: "Termos de
* Uso", "Política de Privacidade" e um "Continuar" desabilitado. Três controles,
* nenhum deles uma saída. Quem chegou ali por engano — foi convidado para uma
* organização nova, trocou para ver o que era — perdia o caminho de volta para
* a organização onde estava trabalhando, e a única saída real era limpar cookie
* ou adivinhar a URL de logout.
*
* É o invariante "nenhuma demanda sem próximo passo" pelo avesso: a tela pede
* seis passos de configuração de quem talvez só quisesse espiar, e não oferece
* a porta de trás. Configurar continua sendo o caminho principal — este botão
* não o atropela, fica ao lado dele no cabeçalho.
*
* ⚠️ Navega EXPLICITAMENTE depois de trocar. `setActiveOrg` revalida `/app`, não
* `/onboarding`, então o layout desta rota não re-renderiza sozinho: sem o
* `replace`, o cookie mudaria e a pessoa continuaria olhando o wizard da
* organização que ela acabou de deixar.
*/
export function OutrasOrganizacoes({
outras,
}: {
outras: Array<{ id: string; nome: string }>;
}) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
if (outras.length === 0) return null;
const trocar = (id: string) =>
startTransition(async () => {
const r = await setActiveOrg(id);
if (r.ok) router.replace("/app/inbox");
});
// Uma organização só: a escolha já está feita, e um menu de um item é
// cerimônia. O rótulo diz PARA ONDE se vai, não "trocar" — quem está preso
// aqui quer o nome do lugar de onde veio.
if (outras.length === 1) {
const unica = outras[0]!;
return (
<Button
variant="ghost"
size="sm"
data-testid="sair-do-onboarding"
disabled={isPending}
onClick={() => trocar(unica.id)}
className="gap-1.5"
>
<ArrowBendUpLeft size={14} weight="bold" aria-hidden />
<span className="max-w-[180px] truncate">Voltar para {unica.nome}</span>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
data-testid="sair-do-onboarding"
disabled={isPending}
className="gap-1.5"
>
<ArrowBendUpLeft size={14} weight="bold" aria-hidden />
<span>Ir para outra organização</span>
<CaretDown size={12} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[220px]">
{outras.map((o) => (
<DropdownMenuItem
key={o.id}
data-testid={`sair-do-onboarding-item-${o.id}`}
onClick={() => trocar(o.id)}
>
<span className="truncate">{o.nome}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+15 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { loadOnboardingState } from "@/app/actions/onboarding/_shared";
import { Stepper } from "./_components/Stepper";
import { OutrasOrganizacoes } from "./_components/OutrasOrganizacoes";
import { SkipToEnd } from "./_components/SkipToEnd";
import { branding } from "@/lib/branding";
import { passosVisiveis } from "@/lib/onboarding/passos";
@@ -34,7 +35,20 @@ export default async function OnboardingLayout({ children }: { children: React.R
<p className="text-xs uppercase tracking-wider text-muted-foreground">{branding().name}</p>
<h1 className="text-lg font-semibold tracking-tight">{activeOrg.name}</h1>
</div>
{isDev ? <SkipToEnd /> : null}
<div className="flex items-center gap-1">
{/*
A SAÍDA, para quem tem outra organização. Ver o cabeçalho de
`OutrasOrganizacoes`: sem ela, trocar de organização pelo seletor
do topo levava a um wizard sem porta de volta — o layout de `/app`
sai da árvore e leva o `TenantSwitcher` junto.
*/}
<OutrasOrganizacoes
outras={user.organizations
.filter((o) => o.organization_id !== activeOrg.orgId)
.map((o) => ({ id: o.organization_id, nome: o.organization_name }))}
/>
{isDev ? <SkipToEnd /> : null}
</div>
</div>
<div className="mx-auto w-full max-w-3xl px-4 pb-2">
<Stepper passos={passos} />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 136 KiB

+40 -2
View File
@@ -42,7 +42,27 @@ const admin = createClient(credenciais.url, credenciais.serviceRole, {
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const ORG_B_SLUG = "e2e-segunda-org";
/**
* ⚠️ SLUG PRÓPRIO, e a troca dele é o conserto de um CI vermelho.
*
* Isto era `"e2e-segunda-org"` — o MESMO slug que `scripts/seed-e2e-funis.ts:50`
* usa. Os dois seeds construíam estados diferentes na mesma linha de
* `organizations`, e quem rodava primeiro vencia: o de funis cria a org **sem
* `onboarded_at`** (ele só quer o funil homônimo), e este aqui, ao encontrar a
* linha por slug, devolvia o id sem corrigir nada.
*
* Na parte 2 do e2e o `pipelines-gestao` roda antes do `agenda-escopo`, então a
* org B chegava sem onboarding — e `app/app/layout.tsx` manda para `/onboarding`
* toda org nesse estado. A troca de organização terminava num redirect, o shell
* saía da árvore junto com o seletor, e a spec reprovava com `element(s) not
* found` depois de alguns `unexpected value "disabled"`. Medido no run
* 33164258175 e reproduzido aqui zerando o `onboarded_at` à mão.
*
* Dois seeds disputando uma linha é o defeito; corrigir o `onboarded_at` no
* caminho de "já existe" trataria o sintoma e deixaria a disputa de pé, para o
* próximo campo que divergisse. Slug próprio remove a disputa.
*/
const ORG_B_SLUG = "e2e-org-b";
const ORG_B_NOME = "E2E Segunda Organização";
/** Um tipo por org, com nome que não existe na outra. É a asserção da spec. */
@@ -63,7 +83,25 @@ async function orgB(): Promise<string> {
.eq("slug", ORG_B_SLUG)
.maybeSingle();
if (error) throw new Error(`buscar org B: ${error.message}`);
if (existente) return (existente as { id: string }).id;
if (existente) {
const id = (existente as { id: string }).id;
/**
* O SEED GARANTE A PRECONDIÇÃO QUE ELE PRÓPRIO EXIGE — mesmo reencontrando a
* org de uma corrida anterior.
*
* Defesa em profundidade ao lado do slug próprio: o que quebrou o CI foi uma
* org **sem `onboarded_at`**, e um seed que devolve o id sem olhar o estado
* herda qualquer coisa que estivesse ali. `update` e não `upsert` porque a
* linha existe; e só este campo, para não pisar no que outra spec configurou.
*/
const { error: fixErr } = await admin
.from("organizations")
.update({ onboarded_at: new Date().toISOString() } as never)
.eq("id", id)
.is("onboarded_at", null);
if (fixErr) throw new Error(`garantir onboarding da org B: ${fixErr.message}`);
return id;
}
const { data, error: insErr } = await admin
.from("organizations")
+11
View File
@@ -74,6 +74,17 @@ async function main(): Promise<void> {
slug: SEGUNDA_SLUG,
legal_name: "E2E Segunda Org LTDA",
display_name: "E2E Segunda Org",
// ⚠️ SEM `onboarded_at` DE PROPÓSITO — e agora isso é o fixture, não um
// descuido.
//
// Esta org existe para o caso do funil homônimo, e ficou sem onboarding
// por omissão. Enquanto ela dividia o slug com o seed de duas
// organizações, essa omissão vazava para uma spec que não a pediu e
// derrubava o CI (ver o cabeçalho de `seed-e2e-duas-organizacoes.ts`).
// Com os slugs separados, ela passa a ser o único lugar do harness com
// uma organização legitimamente NÃO configurada — que é exatamente o
// estado de que `troca-de-organizacao-tem-volta.spec.ts` precisa para
// provar que dá para sair do wizard.
})
.select("id")
.single();
@@ -0,0 +1,129 @@
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { test, expect, type Page } from "@playwright/test";
/**
* TROCAR DE ORGANIZAÇÃO TEM VOLTA — mesmo quando a organização de destino
* ainda não foi configurada.
*
* ─── O defeito, e como ele apareceu ──────────────────────────────────────
*
* `app/app/layout.tsx` manda para `/onboarding` toda organização ativa sem
* `onboarded_at`. Então trocar de organização pelo seletor do topo — um clique,
* a ação mais banal do cabeçalho — podia levar ao wizard de seis passos da
* organização nova **e tirar o seletor da tela junto**: o layout de `/app` sai
* inteiro da árvore, e o `TenantSwitcher` mora nele.
*
* O que sobrava, medido no snapshot de uma falha do CI (run 33164258175):
* "Termos de Uso", "Política de Privacidade" e um "Continuar" desabilitado.
* Três controles, nenhuma saída. Quem foi convidado para uma organização nova e
* trocou para ver o que era ficava sem caminho de volta — limpar cookie ou
* adivinhar a URL de logout.
*
* O vermelho do CI era outro (dois seeds disputando o mesmo slug deixavam a org
* B sem onboarding), e essa parte se conserta no harness. Esta spec prende o
* que o vermelho EXPÔS, que é de produto e sobrevive ao conserto do seed.
*
* ─── Por que a organização do seed de funis ──────────────────────────────
*
* Depois da separação dos slugs, `e2e-segunda-org` é o único lugar do harness
* com uma organização legitimamente **não configurada** — e é exatamente o
* fixture de que este caso precisa. Está escrito lá, no `insert`, para ninguém
* "consertar" a ausência do `onboarded_at` achando que é descuido.
*/
const RAIZ = path.resolve(__dirname, "../..");
interface Creds {
password: string;
users: Record<string, { email: string } | undefined>;
funis?: { segunda_org_id: string };
duas_orgs?: { org_a_id: string };
}
function lerCreds(): Creds {
const p = path.join(RAIZ, ".e2e-creds.json");
if (!fs.existsSync(p)) throw new Error("`.e2e-creds.json` ausente — rode `scripts/seed-e2e-credentials.ts`");
let c = JSON.parse(fs.readFileSync(p, "utf8")) as Creds;
// A spec semeia a própria precondição: depender de `pipelines-gestao` ter
// rodado antes seria depender da ORDEM, que é o defeito que esta suíte já
// pagou mais de uma vez.
if (!c.funis || !c.duas_orgs) {
if (!c.duas_orgs) execFileSync("npx", ["tsx", "scripts/seed-e2e-duas-organizacoes.ts"], { stdio: "inherit" });
if (!c.funis) execFileSync("npx", ["tsx", "scripts/seed-e2e-funis.ts"], { stdio: "inherit" });
c = JSON.parse(fs.readFileSync(p, "utf8")) as Creds;
}
if (!c.funis?.segunda_org_id) throw new Error("o seed de funis não gravou `funis.segunda_org_id`");
if (!c.duas_orgs?.org_a_id) throw new Error("o seed de duas orgs não gravou `duas_orgs.org_a_id`");
return c;
}
async function entrar(page: Page, creds: Creds) {
const usuario = creds.users.manager;
if (!usuario) throw new Error(".e2e-creds.json sem o usuário `manager`");
await page.goto("/login");
await page.getByLabel(/e-?mail/i).fill(usuario.email);
await page.getByLabel(/senha/i).fill(creds.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
}
test.describe.configure({ timeout: 150_000 });
test("trocar para uma organização não configurada leva ao wizard — e dá para voltar", async ({ page }) => {
const creds = lerCreds();
const semOnboarding = creds.funis!.segunda_org_id;
const orgA = creds.duas_orgs!.org_a_id;
await entrar(page, creds);
await page.goto("/app/inbox");
// Ancora na org A e guarda o nome dela — é para cá que a volta tem de trazer.
const seletor = page.getByTestId("tenant-switcher");
await expect(seletor).toBeVisible({ timeout: 20_000 });
await seletor.click();
await page.getByTestId(`tenant-switcher-item-${orgA}`).click();
await expect(seletor).toBeEnabled({ timeout: 60_000 });
const nomeDaOrgA = (await seletor.textContent())!.trim();
expect(nomeDaOrgA.length, "o seletor não anuncia o nome da organização ativa").toBeGreaterThan(0);
// ── a troca que prendia ────────────────────────────────────────────────
await seletor.click();
await page.getByTestId(`tenant-switcher-item-${semOnboarding}`).click();
// O wizard é o destino CORRETO — a organização não está configurada mesmo. O
// defeito nunca foi vir para cá; foi não ter como sair.
await page.waitForURL(/\/onboarding/, { timeout: 60_000 });
await expect(
page.getByTestId("tenant-switcher"),
"o seletor de organização sobreviveu ao redirect — se ele está aqui, este caso perdeu o objeto",
).toHaveCount(0);
// ── A SAÍDA ────────────────────────────────────────────────────────────
const saida = page.getByTestId("sair-do-onboarding");
await expect(
saida,
"o wizard não oferece caminho de volta — quem trocou de organização por engano fica preso aqui",
).toBeVisible({ timeout: 15_000 });
await saida.click();
// Com mais de uma organização de destino a saída abre um menu; com uma só, o
// clique já basta. Cobrimos o caminho que este ambiente produz.
const item = page.getByTestId(`sair-do-onboarding-item-${orgA}`);
if ((await item.count()) > 0) await item.click();
// ── e a volta CHEGA: o produto de novo, na organização de antes ────────
await page.waitForURL(/\/app(\/|$)/, { timeout: 60_000 });
const seletorDeVolta = page.getByTestId("tenant-switcher");
await expect(seletorDeVolta, "voltei para o produto e o seletor não reapareceu").toBeVisible({
timeout: 20_000,
});
await expect(seletorDeVolta).toBeEnabled({ timeout: 60_000 });
await expect(
seletorDeVolta,
`a volta não trouxe para "${nomeDaOrgA}" — trocou de lugar, não desfez a troca`,
).toContainText(nomeDaOrgA, { timeout: 20_000 });
await page.screenshot({ path: "evidence/onboarding/troca-de-org-tem-volta.png" });
});
+93
View File
@@ -0,0 +1,93 @@
/**
* O WIZARD TEM PORTA DE SAÍDA — para quem tem outra organização, e só.
*
* ─── O defeito que esta cerca fecha ──────────────────────────────────────
*
* `app/app/layout.tsx` manda para `/onboarding` toda organização ativa sem
* `onboarded_at`, e o layout de `/app` sai da árvore junto com o
* `TenantSwitcher`. Quem trocou de organização pelo seletor do topo caía num
* wizard de seis passos com três controles na tela — "Termos de Uso",
* "Política de Privacidade" e um "Continuar" desabilitado — e nenhuma saída.
* Medido no snapshot de uma falha do CI, não deduzido.
*
* ─── O que este arquivo prova, e o que fica para a spec em tela ──────────
*
* Aqui: as três formas do controle (nenhuma outra organização, uma, várias) e o
* fato de que ele NAVEGA depois de trocar. A navegação é o detalhe que mais
* facilmente se perde numa refatoração e falha em silêncio: `setActiveOrg`
* revalida `/app`, não `/onboarding`, então sem o `replace` o cookie muda e a
* pessoa continua olhando o wizard da organização que acabou de deixar — a tela
* fica idêntica, e o clique parece não ter feito nada.
*
* A jornada inteira (trocar, cair no wizard, voltar) é da
* `tests/e2e/troca-de-organizacao-tem-volta.spec.ts`, que dirige o browser.
*/
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
const setActiveOrg = vi.fn(async () => ({ ok: true }));
const replace = vi.fn();
vi.mock("@/app/actions/shell/setActiveOrg", () => ({
setActiveOrg: (...args: unknown[]) => setActiveOrg(...(args as [])),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ replace }) }));
import { OutrasOrganizacoes } from "@/app/onboarding/_components/OutrasOrganizacoes";
afterEach(cleanup);
beforeEach(() => {
setActiveOrg.mockClear();
replace.mockClear();
});
describe("a saída do wizard", () => {
it("não existe para quem só tem esta organização", () => {
// Quem instalou o sistema e está configurando a própria empresa não tem
// para onde voltar — oferecer o controle prometeria uma ação vazia, que é
// o anti-pattern de controle decorativo desta base.
render(<OutrasOrganizacoes outras={[]} />);
expect(screen.queryByTestId("sair-do-onboarding")).toBeNull();
});
it("com uma organização, o rótulo diz PARA ONDE se vai", () => {
// "Trocar de organização" faria a pessoa abrir um menu para descobrir a
// única resposta. Quem está preso aqui quer o nome do lugar de onde veio.
render(<OutrasOrganizacoes outras={[{ id: "o1", nome: "Clínica Vida" }]} />);
expect(screen.getByTestId("sair-do-onboarding")).toHaveTextContent("Voltar para Clínica Vida");
});
it("clicar troca a organização E NAVEGA — as duas coisas", async () => {
// ⚠️ A navegação é metade do conserto. `setActiveOrg` revalida `/app`, não
// `/onboarding`: sem o `replace`, o cookie muda e o wizard continua na tela,
// idêntico. O clique pareceria não ter feito nada.
render(<OutrasOrganizacoes outras={[{ id: "o1", nome: "Clínica Vida" }]} />);
fireEvent.click(screen.getByTestId("sair-do-onboarding"));
await waitFor(() => expect(setActiveOrg).toHaveBeenCalledWith("o1"));
await waitFor(() => expect(replace).toHaveBeenCalledWith("/app/inbox"));
});
it("com várias, vira menu — e cada organização é um destino", async () => {
// ⚠️ `userEvent` e não `fireEvent.click`: o menu do Radix abre no PONTEIRO,
// não no clique sintético. Com `fireEvent` o gatilho fica `data-state=
// "closed"` e o caso reprovaria por causa da ferramenta, acusando o produto.
const user = userEvent.setup();
render(
<OutrasOrganizacoes
outras={[
{ id: "o1", nome: "Clínica Vida" },
{ id: "o2", nome: "Studio Norte" },
]}
/>,
);
const gatilho = screen.getByTestId("sair-do-onboarding");
expect(gatilho).toHaveTextContent("Ir para outra organização");
await user.click(gatilho);
// O menu do Radix monta ao abrir; os dois destinos têm testid próprio para a
// spec em tela poder escolher um deles sem depender da ordem.
expect(await screen.findByTestId("sair-do-onboarding-item-o1")).toBeInTheDocument();
expect(screen.getByTestId("sair-do-onboarding-item-o2")).toBeInTheDocument();
});
});