fix(agenda): marcar pela tela dizia "Marcado ✓" e não criava nada

A cerca `agenda-marcar-pela-tela` era um `test.skip` com a condição de saída
escrita — e ninguém volta para conferir se ela venceu. As duas metades já
estavam cumpridas: `GET:95` existe e a tela consome `horarios-livres` em 6
sítios. Terceiro desligador da noite, mesmo formato dos outros dois.

Escrevê-la achou CINCO defeitos que nenhuma leitura de código tinha achado.

1) A TELA MARCAVA NA AGENDA DA PESSOA ERRADA. `onConfirmar` mandava
   `owner_user_id: pessoas[0]?.id` — a primeira da lista —, enquanto os horários
   ofertados vêm de `useHorariosLivres`, que não manda dono e faz a rota resolver
   `tipo.default_owner_user_id`. Medido nesta org: 5 pessoas, só o dono do tipo
   com jornada. O POST devolvia 422 `agenda_disponibilidade_invalida` ("expected
   object, received undefined"). Omitir o campo faz oferta e marcação resolverem
   o dono pela MESMA regra, por construção.

2) E A TELA DIZIA QUE TINHA MARCADO. `onClick` fazia `setMarcado(horario)` ANTES
   de a mutação responder, e continuava mostrando a vista de sucesso quando o
   POST falhava. Dizer que marcou é afirmação sobre o mundo, não sobre a tela:
   agora espera o servidor.

3) UM LAÇO DE REQUISIÇÕES. `de: new Date().toISOString()` era calculado no corpo
   do render e entrava na chave do React Query — milissegundos diferentes a cada
   passagem, cada resposta causando re-render e nova busca. O painel nunca
   estabilizava: 42 dias "sem horário" com a rota respondendo 200 e slots reais.

4) NÃO HAVIA COMO ESCOLHER O TIPO. `const tipo = tiposIniciais[0] ?? null`, e
   `page.tsx` ordena por nome — a tela marcava sempre o primeiro em ordem
   alfabética. Nesta org: "Atendimento", "Consulta", "Consulta E2E", "Reunião", e
   só "Atendimento" era alcançável. As categorias existiam no banco, no seed e na
   API; a tela oferecia uma.

5) O `useAgendamentos` ERA ÓRFÃO — 1 ocorrência no repo, a própria definição
   (controle: hooks irmãos, 3). A grade não repintava sem F5, e três comentários
   afirmavam que o `GET` "não foi escrito (medido)": prosa verdadeira no dia,
   vencida depois. Montado, com a janela do recorte acompanhando o que a grade
   desenha e fallback só na janela que o servidor pintou — cair para lista vazia
   é pior de aparência e melhor de verdade que mostrar a semana errada.

De quebra, a listagem passou a carregar `contatoNome`: sem isso montar o hook
REGREDIRIA o conserto do "com quem", e o agente recebia um uuid cru onde devia
dizer "você já tem consulta marcada, Maria".

TRÊS VEZES A RÉGUA ESTAVA ERRADA, NÃO O PRODUTO, e está registrado na spec:
contar cartões da grade (há 15 cancelados empilhados — troquei por `faixa-<id>`,
identidade em vez de contagem); `includes("agendamentos")` casando o GET junto
com o POST (o método entrou no filtro); e `toHaveCount(1)` num horário que tem
cancelados. A spec carrega instrumentação permanente: quando falhar, ela imprime
o que a rota respondeu — responder isso custou uma caçada inteira da primeira vez.

Ela também LIMPA o que cria. Sem isso o horário avançava 30min por corrida —
foi assim que provei que o verde de 2,1s não era vazio (13:00, 13:30, 14:00 em
três execuções) — e é por isso que não pode ficar assim.

Conjunto de agenda: 28 passed, 0 skipped. typecheck 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THCeiPxospLw1pr3dJG1z7
This commit is contained in:
Rafael Melgaço
2026-08-27 00:58:50 -03:00
co-authored by Claude Opus 5
parent 265759fe8d
commit b41c66ea01
6 changed files with 410 additions and 60 deletions
+117 -21
View File
@@ -1,6 +1,6 @@
"use client";
import { addDays, format, startOfWeek } from "date-fns";
import { addDays, endOfMonth, format, startOfDay, startOfMonth, startOfWeek } from "date-fns";
import { ptBR } from "date-fns/locale";
import * as React from "react";
@@ -15,6 +15,7 @@ import { EmptyAgenda } from "@/components/empty";
import { Button } from "@/components/ui/button";
import { PainelDeMarcacao } from "@/components/agenda/PainelDeMarcacao";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAgendamentos } from "@/hooks/agenda/useAgendamentos";
import { useHorariosLivres } from "@/hooks/agenda/useHorariosLivres";
import { useMarcarAgendamento } from "@/hooks/agenda/useMarcarAgendamento";
import { usePessoasDaAgenda } from "@/hooks/agenda/usePessoasDaAgenda";
@@ -63,13 +64,20 @@ export function AgendaClient({
googleConfigurado: boolean;
faltaNoGoogle: string[];
/** Tipos ativos, resolvidos no servidor: não há rota que os liste ainda. */
tiposIniciais: Array<{ id: string; nome: string; duracaoMin: number }>;
tiposIniciais: Array<{ id: string; nome: string; duracaoMin: number; donoId: string | null }>;
/** A semana corrente, resolvida no servidor: `GET /agendamentos` não existe. */
agendamentosIniciais: Agendamento[];
}) {
const [marcando, setMarcando] = React.useState(false);
const marcar = useMarcarAgendamento();
const tipo = tiposIniciais[0] ?? null;
// ⚠️ ERA `tiposIniciais[0] ?? null` — uma constante, sem seletor em lugar
// nenhum. `page.tsx` ordena os tipos por NOME, então a tela marcava sempre o
// primeiro em ordem alfabética e não havia como marcar outro: numa org com
// "Atendimento", "Consulta", "Reunião", só "Atendimento" era alcançável pela
// tela. As categorias existiam no banco, no seed e na API — e a tela oferecia
// uma. Achado escrevendo a spec de marcar, não lendo o código.
const [tipoId, setTipoId] = React.useState<string | null>(() => tiposIniciais[0]?.id ?? null);
const tipo = tiposIniciais.find((t) => t.id === tipoId) ?? tiposIniciais[0] ?? null;
const [visao, setVisao] = React.useState<VisaoDaAgenda>("semana");
const [isolada, setIsolada] = React.useState<string | null>(null);
const [ancora, setAncora] = React.useState(() => new Date());
@@ -81,16 +89,31 @@ export function AgendaClient({
// aqui.
const { data: pessoas = [] } = usePessoasDaAgenda();
// A JANELA DE BUSCA PRECISA SER ESTÁVEL, e não era.
//
// ⚠️ Isto era `de: new Date().toISOString()` calculado no CORPO do render. A
// chave do React Query inclui o recorte, e `new Date()` devolve milissegundos
// diferentes a cada passagem — então cada resposta causava re-render, que
// gerava chave nova, que disparava outra busca. O painel nunca estabilizava:
// `horarios` ficava `undefined` entre as idas, `horariosPorDia` nascia vazio e
// TODO dia aparecia "sem horário" — com a rota respondendo 200 e slots reais.
//
// Medido pela spec de marcar, que capturou as respostas: cinco 200 seguidos
// com vagas, e a tela mostrando 42 dias apagados. Em produção isto é um laço
// de requisições por usuário com o painel aberto.
//
// `useMemo` sem dependência de tempo: a janela é fixada quando o painel abre.
const janelaDeBusca = React.useMemo(
() => ({ de: new Date().toISOString(), ate: addDays(new Date(), 30).toISOString() }),
// A janela só precisa mudar quando o painel REABRE ou o tipo muda — nunca a
// cada render. `marcando` na lista é o que a renova entre duas aberturas.
[marcando, tipo?.id],
);
// Os horários vêm da rota real — a mesma que a IA usa, então tela e agente
// oferecem exatamente os mesmos horários. Só consulta quando o painel abre.
const { data: horarios } = useHorariosLivres(
marcando && tipo
? {
event_type_id: tipo.id,
de: new Date().toISOString(),
ate: addDays(new Date(), 30).toISOString(),
}
: null,
marcando && tipo ? { event_type_id: tipo.id, de: janelaDeBusca.de, ate: janelaDeBusca.ate } : null,
);
const horariosPorDia = React.useMemo(() => {
@@ -103,11 +126,45 @@ export function AgendaClient({
return mapa;
}, [horarios]);
// OS AGENDAMENTOS SÃO REAIS, vindos do servidor. `GET /api/v1/agenda/agendamentos`
// ainda não existe (a rota tem POST, PATCH e DELETE), então a `page.tsx`
// consulta e passa por prop. O que falta até o GET subir é atualizar sem
// recarregar — não o dado.
const todos: Agendamento[] = agendamentosIniciais;
// OS AGENDAMENTOS SÃO REAIS, e agora TAMBÉM se atualizam sem recarregar.
//
// ⚠️ O comentário que estava aqui dizia que `GET /api/v1/agenda/agendamentos`
// "ainda não existe (a rota tem POST, PATCH e DELETE)". Era verdade quando foi
// escrito e VENCEU: `grep -n "^export async function" app/api/v1/agenda/agendamentos/route.ts`
// devolve GET:95. A prosa descrevia um estado, o estado mudou, e a frase ficou
// — junto com o `useAgendamentos`, que existia inteiro e não era montado por
// ninguém (1 ocorrência no repo: a própria definição).
//
// A prop do RSC segue sendo a PRIMEIRA pintura (sem piscar, sem spinner) e o
// hook assume dali: `useMarcarAgendamento` já invalida `["agenda"]`, então
// marcar pela tela repinta a grade sozinho.
// O recorte acompanha o que a grade DESENHA — mesma visão, mesma âncora.
// Instante ISO, nunca o filtro `dia`: o cabeçalho do hook mede por que
// (`dia=` corta em UTC e some com o compromisso das 22h no fuso de São Paulo).
const recorteDaGrade = React.useMemo(() => {
const inicio =
visao === "mes"
? startOfMonth(ancora)
: visao === "semana"
? startOfWeek(ancora, { weekStartsOn: 0 })
: startOfDay(ancora);
const fim =
visao === "mes" ? addDays(endOfMonth(ancora), 1) : addDays(inicio, visao === "semana" ? 7 : 1);
return { de: inicio.toISOString(), ate: fim.toISOString() };
}, [visao, ancora]);
// A janela que o SERVIDOR pintou. Sem esta comparação, navegar para outra
// semana mostraria os compromissos DESTA por um instante — o fallback estaria
// respondendo a uma pergunta que ninguém fez. Cair para lista vazia é pior de
// aparência e melhor de verdade: a grade fica vazia por um piscar, em vez de
// mostrar compromisso no dia errado.
const recorteDoServidor = React.useRef(recorteDaGrade).current;
const naJanelaDoServidor =
recorteDaGrade.de === recorteDoServidor.de && recorteDaGrade.ate === recorteDoServidor.ate;
const { data: agendamentosVivos } = useAgendamentos(recorteDaGrade);
const todos: Agendamento[] =
agendamentosVivos ?? (naJanelaDoServidor ? agendamentosIniciais : []);
const agendamentos = React.useMemo(
() => (isolada === null ? todos : todos.filter((a) => a.responsavelId === isolada)),
@@ -261,12 +318,43 @@ export function AgendaClient({
<SheetHeader>
<SheetTitle>Novo agendamento</SheetTitle>
</SheetHeader>
{tiposIniciais.length > 1 && (
<div className="mt-4" data-testid="tipos-de-agendamento">
<p className="mb-2 text-xs font-medium text-text-muted">Tipo de agendamento</p>
<div className="flex flex-wrap gap-1.5">
{tiposIniciais.map((t) => (
<button
key={t.id}
type="button"
data-testid={`tipo-${t.id}`}
aria-pressed={t.id === tipo?.id}
onClick={() => setTipoId(t.id)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors duration-fast",
t.id === tipo?.id
? "border-transparent bg-accent text-accent-foreground"
: "border-border text-text-muted hover:border-border-strong hover:text-text",
)}
>
{t.nome}
<span className="ml-1 opacity-70 tabular-nums">{t.duracaoMin}min</span>
</button>
))}
</div>
</div>
)}
{tipo && (
<div className="mt-4">
<PainelDeMarcacao
ancora={new Date()}
agora={new Date()}
responsavel={pessoas[0] ?? { id: "", nome: "Você", trilha: 1 }}
responsavel={
// O DONO DO TIPO, não o primeiro da lista. A tela dizia "com
// <primeira pessoa>" enquanto oferecia a jornada de outra —
// e marcava na agenda da primeira, que não tinha jornada.
pessoas.find((p) => p.id === tipo.donoId) ??
pessoas[0] ?? { id: "", nome: "Você", trilha: 1 }
}
tipo={tipo.nome}
duracaoMin={tipo.duracaoMin}
horariosPorDia={horariosPorDia}
@@ -276,11 +364,19 @@ export function AgendaClient({
// ESTE é o fio que faltava. Sem ele o "Marcado ✓" era estado
// local do React e nenhuma linha nascia no banco.
onConfirmar={(instante) => {
marcar.mutate({
event_type_id: tipo.id,
starts_at: instante,
owner_user_id: pessoas[0]?.id,
});
// ⚠️ SEM `owner_user_id`, e é isto que conserta o 422.
//
// Isto mandava `pessoas[0]?.id` — a PRIMEIRA pessoa da lista.
// Os horários oferecidos vêm de `useHorariosLivres`, que NÃO
// manda dono, então a rota resolve `tipo.default_owner_user_id`.
// A tela oferecia a agenda de um e marcava na de outro: medido
// nesta org, 5 pessoas e só o dono do tipo com jornada, e o POST
// devolvia `agenda_disponibilidade_invalida` ("expected object,
// received undefined") enquanto a tela dizia "Marcado ✓".
//
// Omitir é o que faz oferta e marcação resolverem o dono pela
// MESMA regra (`_handler.ts:96`), por construção e não por sorte.
return marcar.mutateAsync({ event_type_id: tipo.id, starts_at: instante });
}}
/>
</div>
+14 -4
View File
@@ -53,9 +53,15 @@ export default async function AgendaPage() {
* A SEMENTE vem do servidor, e não de um hook — porque a rota de leitura ainda
* não existe.
*
* `GET /api/v1/agenda/agendamentos` não foi escrito: a rota tem POST, PATCH e
* DELETE (medido). Sem ela, um hook no cliente não tem onde bater, e o cookie
* `httpOnly` impede o supabase-js do browser de consultar direto.
* ⚠️ ESTE PARÁGRAFO VENCEU e foi reescrito. Ele dizia que
* `GET /api/v1/agenda/agendamentos` "não foi escrito (medido)" — e o GET existe:
* `grep -n "^export async function" app/api/v1/agenda/agendamentos/route.ts` → GET:95.
* A medição estava certa no dia; a frase não tinha como saber que envelheceu.
*
* O que esta consulta faz HOJE é a PRIMEIRA PINTURA: o RSC entrega a grade já
* desenhada, sem piscar e sem spinner, e o `useAgendamentos` assume a partir
* dali para as atualizações. O cookie `httpOnly` segue impedindo o supabase-js
* do browser de consultar direto — por isso o caminho do cliente é a rota.
*
* O servidor PODE: ele tem a sessão, e a RLS filtra por organização como em
* qualquer outra tela. Então a Agenda nasce com dado REAL em vez de vazia — o
@@ -75,7 +81,7 @@ export default async function AgendaPage() {
const [{ data: tipos }, { data: linhas }] = await Promise.all([
supabase
.from("calendar_event_types")
.select("id, name, duration_minutes, location_kind, location_details, is_active")
.select("id, name, duration_minutes, location_kind, location_details, is_active, default_owner_user_id")
.eq("is_active", true)
.order("name"),
supabase
@@ -100,6 +106,10 @@ export default async function AgendaPage() {
id: t.id,
nome: t.name,
duracaoMin: t.duration_minutes,
// Quem DE FATO atende este tipo. Sem isto a tela mostrava o primeiro da
// lista de pessoas como responsável e marcava na agenda dele — enquanto
// os horários oferecidos vinham da jornada de outra pessoa.
donoId: t.default_owner_user_id ?? null,
}))}
agendamentosIniciais={(linhas ?? []).map((a) => ({
id: a.id,
+20 -2
View File
@@ -73,7 +73,7 @@ export function PainelDeMarcacao({
* decisão; o produto não avisar que não ia mandar é um bug.
*/
quemSeraAtendido?: { nome: string; aceitaMensagem: boolean };
onConfirmar?: (instante: string) => void;
onConfirmar?: (instante: string) => void | Promise<unknown>;
className?: string;
}) {
const [dia, setDia] = React.useState<Date | null>(null);
@@ -324,7 +324,25 @@ export function PainelDeMarcacao({
<Button
size="sm"
data-testid="confirmar-marcacao"
onClick={() => { setMarcado(horario); onConfirmar?.(horario.instante); }}
onClick={async () => {
// ⚠️ ERA `setMarcado(horario); onConfirmar?.(...)` — nesta ordem
// e sem esperar. A vista de sucesso aparecia por estado local do
// React, ANTES de o servidor responder, e continuava aparecendo
// quando o POST falhava. Medido: a rota devolvia 422
// `agenda_disponibilidade_invalida` e a tela dizia "Marcado ✓".
//
// Dizer que marcou é uma AFIRMAÇÃO sobre o mundo, não sobre a
// tela. Ela agora espera o servidor; se der erro, o toast do
// `showApiError` aparece e o painel fica onde estava, com o
// horário ainda escolhido para tentar de novo.
try {
await onConfirmar?.(horario.instante);
setMarcado(horario);
} catch {
// silêncio proposital: quem reporta é o `showApiError` da
// mutação, e engolir aqui não esconde nada que não seja dito.
}
}}
>
Confirmar
</Button>
+6
View File
@@ -24,6 +24,7 @@ interface AgendamentoListado {
situacao: string;
donoId: string | null;
contatoId: string | null;
contatoNome: string | null;
}
export interface RecorteDaGrade {
@@ -82,6 +83,11 @@ export function useAgendamentos(recorte: RecorteDaGrade | null) {
termina: a.terminaEm,
origem: "ui" as const,
situacao: a.situacao as Agendamento["situacao"],
// Sem esta linha, montar o hook REGREDIRIA o conserto do "com quem":
// a prop do servidor traz o nome, e o refetch o apagaria da grade.
// Campo novo é optional e a rota pode ainda não mandá-lo — `?? undefined`
// mantém o wire tolerante a servidor mais velho que o cliente.
quemSeraAtendido: a.contatoNome ?? undefined,
}));
} catch (err) {
showApiError(err);
+17 -1
View File
@@ -331,6 +331,7 @@ export interface AgendamentoListado {
situacao: string;
donoId: string | null;
contatoId: string | null;
contatoNome: string | null;
}
export interface ParametrosDaLista {
@@ -371,6 +372,14 @@ export type ResultadoDaLista =
| { ok: true; agendamentos: AgendamentoListado[] }
| { ok: false; codigo: "erro_interno" | "sem_alvo"; motivoParaOperador: string; motivoParaCliente: string };
/** O embed do PostgREST vem objeto ou array conforme o gerador de tipos; aceite os dois. */
function nomeDoContato(
c: { name: string | null; display_name: string | null } | { name: string | null; display_name: string | null }[] | null | undefined,
): string | null {
const alvo = Array.isArray(c) ? c[0] : c;
return alvo?.name ?? alvo?.display_name ?? null;
}
export async function listaAgendamentos(
supabase: SupabaseClient,
organizationId: string,
@@ -418,7 +427,9 @@ export async function listaAgendamentos(
let q = supabase
.from("calendar_appointments")
.select("id, title, starts_at, ends_at, time_zone, status, owner_user_id, contact_id")
.select(
"id, title, starts_at, ends_at, time_zone, status, owner_user_id, contact_id, contacts(name, display_name)",
)
.eq("organization_id", organizationId)
.order("starts_at", { ascending: true })
.limit(params.limite);
@@ -458,6 +469,11 @@ export async function listaAgendamentos(
situacao: String(l.status),
donoId: l.owner_user_id ? String(l.owner_user_id) : null,
contatoId: l.contact_id ? String(l.contact_id) : null,
// O ID sozinho não serve a nenhum dos dois consumidores: a grade precisa do
// nome para dizer "com quem", e o AGENTE recebia um uuid cru onde devia
// dizer "você já tem consulta marcada, Maria". Mesma coluna que a tela do
// produto lê, mesmo precedente de `name` antes de `display_name`.
contatoNome: nomeDoContato(l.contacts),
})),
};
}
+236 -32
View File
@@ -1,34 +1,238 @@
/**
* A PROVA EM TELA DA FRENTE 1 (API + motor) — ainda NÃO escrita, e por isso
* existe como `skip` com motivo em vez de como promessa num relatório.
*
* ─── Por que este arquivo nasce vazio ─────────────────────────────────────
*
* A frente 1 é API: ela não tem pixel próprio. A DECISÃO 21 permite que uma
* frente sem tela feche com prova de caminho real, DESDE QUE declare quem a
* prova em tela — e a 21.3 fechou o furo dessa declaração: o endereço não é
* citado num relatório que alguém precisa reler, é CRIADO aqui, para entrar em
* `tests/unit/e2e-cobertura-completa.test.ts` como qualquer outra spec.
*
* O que falta para ela deixar de ser `skip`: a tela de marcar da frente 2
* consumindo `GET /api/v1/agenda/horarios-livres` e o POST de criação. Enquanto
* a tela não existe, não há clique para dirigir.
*
* ─── O que esta spec vai provar quando existir ────────────────────────────
*
* 1. A grade mostra os horários que a rota devolveu, no fuso de APRESENTAÇÃO de
* quem olha — não no fuso da jornada, e não no do servidor (que em produção é
* UTC, medido: `node:22-alpine` sem `tzdata` e o serviço `app` sem `TZ`).
* 2. Marcar pela tela cria o agendamento e o horário SOME da grade sem F5.
* 3. Um bloqueio de dia inteiro deixa o dia sem horário E a tela diz POR QUÊ —
* "você ainda não publicou seus horários" é diferente de "não tenho vaga", e
* as duas chegam como a mesma lista vazia se a tela não distinguir.
* 4. Schedule mal configurado devolve 422 com motivo e a tela mostra o motivo,
* em vez de uma grade vazia silenciosa.
*/
import { test } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
test.skip("marcar um horário pela tela e vê-lo sumir da grade", async () => {
// Bloqueada pela frente 2: a tela de marcar ainda não consome a rota.
// Ver o cabeçalho deste arquivo para o que ela vai afirmar.
import { test, expect } from "@playwright/test";
/**
* A PROVA EM TELA DA FRENTE 1 (API + motor) — agora ESCRITA, e o caminho até aqui
* é o registro que interessa.
*
* ─── Este arquivo foi um DESLIGADOR por engano ────────────────────────────
*
* Ele nasceu como `test.skip` com a condição de saída escrita no cabeçalho:
* *"o que falta para deixar de ser skip: a tela de marcar consumindo
* `GET /api/v1/agenda/horarios-livres` e o POST de criação"*. Declarar a
* condição foi a coisa certa a fazer — e **ninguém volta para conferir se ela
* venceu**. As duas metades já estavam cumpridas quando isto foi medido:
*
* grep -n "^export async function" app/api/v1/agenda/agendamentos/route.ts
* # GET:95 POST:145 PATCH:149 DELETE:153
* grep -rn "horarios-livres|useHorariosLivres" app/app/agenda components/agenda hooks/agenda | wc -l
* # 6
*
* E havia um segundo desligador junto, do mesmo tipo: `hooks/agenda/useAgendamentos.ts`
* existia inteiro, bem escrito, e tinha **1 ocorrência no repo — a própria
* definição**. Controle da mesma sonda: os hooks irmãos, 3. A grade não se
* atualizava sozinha porque ninguém montou o hook, e três comentários no código
* afirmavam que o `GET` "não foi escrito (medido)" — prosa verdadeira no dia em
* que foi escrita, vencida depois.
*
* ─── O que esta spec prova, e o que ela AINDA não prova ───────────────────
*
* O cabeçalho antigo prometia quatro asserções. Estão escritas duas, e as outras
* duas ficam nomeadas aqui em vez de sumirem em silêncio — promessa que encolhe
* sem aviso é como a condição de saída acima virou desligador.
*
* [✓] 1. Marcar pela tela faz o compromisso aparecer na grade SEM F5.
* É a asserção que o hook órfão bloqueava. Não há `reload()` nesta
* spec de propósito: recarregar provaria o servidor, não a tela.
* [✓] 2. A grade desenha o horário no fuso de APRESENTAÇÃO. Provado pelo par:
* o rótulo escolhido no painel tem de reaparecer na grade. Se a grade
* renderizasse em UTC (o fuso do contêiner em produção — `node:22-alpine`
* sem `tzdata`), os dois números divergiriam em três horas.
* [ ] 3. Bloqueio de dia inteiro deixa o dia sem horário E a tela diz POR QUÊ.
* Falta o fixture de bloqueio; `sem-jornada-publicada` já existe na tela.
* [ ] 4. Schedule mal configurado devolve 422 com motivo e a tela o mostra.
* Falta a rota devolver o motivo por um caminho que a tela leia.
*/
const APP_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
const RAIZ = path.resolve(__dirname, "../..");
interface Creds {
password: string;
users: Record<string, { email: string } | undefined>;
agenda?: { tipo_nome: string; tipo_slug: 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`");
return JSON.parse(fs.readFileSync(p, "utf8")) as Creds;
}
/** Compromissos desenhados na grade — o `aria-label` traz "HH:mm às HH:mm". */
function cartoesDaGrade(page: import("@playwright/test").Page) {
return page.getByTestId("grade-da-agenda").getByRole("button", { name: /\d{2}:\d{2} às \d{2}:\d{2}/ });
}
test("marcar um horário pela tela e vê-lo aparecer na grade — sem recarregar", async ({ page }) => {
const creds = lerCreds();
if (!creds.agenda) throw new Error(".e2e-creds.json sem o bloco `agenda` — rode `scripts/seed-e2e-agenda.ts`");
// `manager`, e NÃO o primeiro da lista: o `admin` do seed tem MFA com challenge,
// e esta spec não é sobre login. Mesmo motivo do molde `agente-marca-consulta`.
const usuario = creds.users.manager;
if (!usuario) throw new Error(".e2e-creds.json sem o usuário `manager`");
// DIAGNÓSTICO PERMANENTE. Quando esta spec falha em "nenhum dia com vaga", a
// pergunta seguinte é sempre a mesma — o que a ROTA respondeu? — e responder
// custou uma caçada inteira da primeira vez. Fica gravado.
const respostas: string[] = [];
page.on("response", (r) => {
const eAgenda =
r.url().includes("/api/v1/agenda/horarios-livres") ||
r.url().includes("/api/v1/agenda/agendamentos");
if (!eAgenda) return;
void r
.text()
.then((t) => respostas.push(`${r.status()} ${r.request().method()} ${r.url().split("/api/v1/agenda/")[1]?.split("?")[0]} ${t.slice(0, 300)}`))
.catch(() => respostas.push(`${r.status()} <corpo ilegível>`));
});
await page.goto(`${APP_URL}/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 });
await page.goto(`${APP_URL}/app/agenda`);
await expect(page.getByTestId("tela-agenda")).toBeVisible({ timeout: 20_000 });
const antes = await cartoesDaGrade(page).count();
// ── marcar, pela tela, como um humano faria ────────────────────────────
await page.getByRole("button", { name: /novo agendamento/i }).click();
await expect(page.getByTestId("painel-de-marcacao")).toBeVisible({ timeout: 10_000 });
// ESCOLHER O TIPO — e este bloco existe porque a spec o achou faltando.
//
// `_client.tsx` tinha `const tipo = tiposIniciais[0] ?? null`: uma CONSTANTE,
// sem seletor em lugar nenhum. E `page.tsx` ordena os tipos por NOME, então a
// tela marcava sempre o primeiro em ordem alfabética. Medido nesta org, que
// tem quatro tipos ativos: "Atendimento", "Consulta", "Consulta E2E", "Reunião"
// — só "Atendimento" era alcançável pela tela, e nenhum teste percebia porque
// nenhum teste marcava pela tela.
//
// Escolher o tipo do seed (e NÃO o primeiro) é o que dá valor a esta asserção:
// se o seletor sumir, o painel volta a "Atendimento" e o horário marcado deixa
// de casar com a jornada do tipo do seed.
await expect(page.getByTestId("tipos-de-agendamento")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: new RegExp(`^${creds.agenda.tipo_nome}`) }).click();
const dia = page.locator('[data-testid^="dia-"]:not([disabled])').first();
try {
await expect(dia).toBeVisible({ timeout: 15_000 });
} catch (erro) {
// A mensagem do `expect` é avaliada ANTES de a asserção rodar, então ela não
// pode carregar o que a rota respondeu. Enriquecer no catch é o que permite
// à falha trazer a causa junto, em vez de mandar a próxima pessoa caçar.
throw new Error(
`nenhum dia com vaga no painel. O que a rota de horários respondeu:\n` +
(respostas.length ? respostas.join("\n") : " (a rota NÃO foi chamada)") +
`\n\n${(erro as Error).message}`,
);
}
await dia.click();
const horario = page.locator('[data-testid^="horario-"]').first();
await expect(horario, "o dia foi escolhido e não veio horário nenhum").toBeVisible({ timeout: 15_000 });
const rotulo = (await horario.getAttribute("data-testid"))!.replace("horario-", "");
await horario.click();
// `confirmacao` é o bloco que aparece ao ESCOLHER o horário — ele contém o
// próprio botão de confirmar. Depois do clique o painel troca para a vista de
// sucesso, e é `ver-na-agenda` que prova que a marcação foi aceita.
await expect(page.getByTestId("confirmacao")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("confirmar-marcacao").click();
await expect(page.getByTestId("ver-na-agenda")).toBeVisible({ timeout: 15_000 });
// ⚠️ `ver-na-agenda` aparece por estado LOCAL do React — `onClick` faz
// `setMarcado(horario)` ANTES de a mutação responder. Então a tela dizer
// "marcado" não é prova de nada, e esta asserção existe para separar as duas
// coisas: o POST tem de ter respondido 2xx.
try {
await expect
// ⚠️ `includes("agendamentos")` sozinho casava também o GET que o
// `useAgendamentos` dispara — a asserção passava pelo verbo errado e eu
// quase concluí que o POST tinha funcionado. O método entra no filtro.
.poll(() => respostas.filter((r) => /^2\d\d POST agendamentos/.test(r)).length, {
timeout: 15_000,
})
.toBeGreaterThan(0);
} catch (erro) {
throw new Error(
"a tela disse que marcou e o POST não respondeu 2xx. Respostas da agenda:\n" +
(respostas.length ? respostas.join("\n") : " (nenhuma)") +
`\n\n${(erro as Error).message}`,
);
}
// ── e agora o ponto inteiro: SEM `page.reload()` ───────────────────────
//
// Recarregar provaria que o POST gravou — que é o servidor, e o
// `agente-marca-consulta` já prova. O que só esta spec pode provar é que a
// TELA repinta: `useMarcarAgendamento` invalida `["agenda"]` e o
// `useAgendamentos` refaz a busca. Enquanto o hook esteve órfão, este bloco
// ficava em `antes` para sempre.
// ⚠️ CONTAR CARTÕES ERA UM PROXY, E O PROXY MENTIA. A janela desta grade tem
// 17 linhas (2 confirmadas, 15 canceladas de execuções anteriores) e a grade
// empilha sobreposições — a contagem ficava em 16 antes e depois, com o POST
// devolvendo 201 e o GET seguinte trazendo a linha nova. Eu quase concluí que
// a repintura não acontecia, quando o que não servia era a régua.
//
// A identidade está disponível e é exata: o POST devolve o `id`, e a grade
// marca cada cartão com `faixa-<id>`. Perguntar pelo ID responde "este
// compromisso está desenhado?", que é a pergunta; contar responde "quantos
// há?", que nunca foi.
const criado = respostas.find((r) => r.startsWith("201 POST agendamentos"));
const idCriado = criado?.match(/"id":"([0-9a-f-]{36})"/)?.[1];
expect(idCriado, `o POST não devolveu id. Respostas:\n${respostas.join("\n")}`).toBeTruthy();
await expect(
page.getByTestId(`faixa-${idCriado}`),
"o compromisso nasceu no banco e a grade não o desenhou sem F5 — " +
"`useMarcarAgendamento` invalida `[\"agenda\"]` e `useAgendamentos` deveria refazer a busca",
).toBeAttached({ timeout: 20_000 });
// CONTROLE DA SONDA, e ele mudou junto com a régua: contar deixou de servir,
// então o controle não pode ser sobre contagem. O que precisa ser provado é
// que `getByTestId("faixa-<id>")` DISTINGUE — um id que não existe tem de dar
// zero. Sem isto, um seletor quebrado casaria qualquer coisa (ou nada) e a
// asserção acima passaria por vacuidade.
await expect(
page.getByTestId("faixa-00000000-0000-4000-8000-000000000000"),
"a sonda de identidade casa um id inexistente — ela não distingue nada",
).toHaveCount(0);
// ── asserção 2: o fuso é o de APRESENTAÇÃO, não o do servidor ──────────
//
// Pelo CARTÃO DO ID CRIADO, não por contagem num horário: há cancelados de
// execuções anteriores no mesmo minuto, e `toHaveCount(1)` falhava por isso —
// a terceira vez nesta spec em que a régua, e não o produto, estava errada.
//
// O rótulo escolhido no painel tem de aparecer no nome acessível do cartão. Em
// produção o contêiner roda em UTC (`node:22-alpine` sem `tzdata`, serviço
// `app` sem `TZ`), então uma grade que desenhasse no fuso do SERVIDOR
// divergiria em três horas para `America/Sao_Paulo` — e este par não bateria.
const cartaoCriado = page.locator(`button:has([data-testid="faixa-${idCriado}"])`);
await expect(
cartaoCriado,
`o horário ${rotulo} foi marcado e o cartão dele não anuncia esse horário`,
).toHaveAttribute("aria-label", new RegExp(`${rotulo} às`), { timeout: 10_000 });
// LIMPEZA. Sem isto, cada corrida deixa um compromisso e o horário escolhido
// avança 30min — foi assim que eu PROVEI que a spec faz trabalho de verdade
// (13:00, 13:30, 14:00 em três corridas), e é exatamente por isso que ela não
// pode continuar assim: uma cerca que consome a agenda vai ficar sem vaga.
//
// `page.request` reusa os cookies da sessão logada, então o DELETE passa pelo
// mesmo caminho de autorização que um humano — não por service role.
const apagou = await page.request.delete(`${APP_URL}/api/v1/agenda/agendamentos`, {
data: { id: idCriado, reason: "limpeza da spec de marcar pela tela" },
});
expect(
apagou.ok(),
`a spec não conseguiu cancelar o que criou (${apagou.status()}): a agenda vai encher a cada corrida`,
).toBe(true);
});