mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
265759fe8d
commit
b41c66ea01
+117
-21
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user