R-002 — Transição sala de espera → chamada exige ACK bilateral

TLDR: ninguém navega da sala de espera para a chamada com base em um sinal enviado uma única vez; a transição só acontece após confirmação bilateral (ack) ou por um dos fallbacks explícitos, e a presença é sempre gravada antes do redirect.

Given / When / Then

Dado que dois participantes estão na sala de espera de uma reunião Quando um deles clica em “Entrar” Então ele entra em estado requesting, reenviando join_request (com requestId idempotente) a cada 1 segundo — nunca navega nesse instante

Dado que um participante em requesting recebe um join_request do outro lado (clique simultâneo) ou um join_ack com o mesmo requestId Quando essa confirmação chega Então ele responde join_ack (se aplicável), grava a própria presença com fetch keepalive (aguardando a conclusão) e só então navega para /app/video/{id}

Dado que um participante já está dentro da chamada Quando ele recebe um join_request (de alguém saindo e reentrando, ou de um cliente legado ainda enviando WaitingForRemote) Então ele responde join_ack imediatamente, permitindo reentrada sem depender da janela de presença

Restrições

  • Semântica do ack: join_ack significa “estou pronto, pode navegar”. Só envia ack quem está em requesting ou já está dentro da call — nunca quem está apenas parado na sala de espera sem ter clicado.
  • Fallback legado (rollout): se requesting há mais de 5s e isRemoteWaiting (sinal WaitingForRemote ou presença HTTP) está true sem nenhum ack ter chegado, confirma mesmo assim — cobre o período de deploy misto (cliente novo ↔ cliente antigo) e o caso do socket estar indisponível.
  • Janela de frescor de presença: last_seen mais novo que 45 segundos é considerado “participante presente” (PRESENCE_FRESHNESS_SECONDS em src/hooks/useMeetingLastSeen.ts). Não é mais 4 minutos.
  • Toda escrita de presença que pode coincidir com navegação/unload usa fetch com keepalive: true (persistPresenceBeforeLeave), nunca axios — o browser garante a entrega mesmo após a página começar a descarregar. sendBeacon não é usado porque o endpoint é PUT.
  • Quem está dentro da chamada mantém presença sempre ativa (useMeetingLastSeen com useInterval: true e isAloneInCall: true fixos em Call.tsx, não só quando sozinho) e responde join_ack a qualquer join_request recebido no canal da wait room (useCallPresence) — é isso que permite reentrada imediata sem esperar a call ficar vazia.
  • O servidor chat-api (chat.trg.club) é um relay puro sem histórico: mensagem emitida enquanto o destinatário está desconectado é perdida para sempre. O reenvio de 1s do requesting é o que compensa essa ausência de garantia de entrega — não adicionar lógica que dependa de “a mensagem sempre chega”.

Teste vinculado

useJoinRoomFlow.test.ts (máquina de estados idle → requesting → confirmed), useParticipantDetection.test.tsx (handshake), useCallPresence.test.ts (ack de reentrada), useMeetingLastSeen.test.ts (keepalive, janela de 45s).