# Execução parcial — `MG-7` App falando com o Firebase

**Story:** `docs/stories/MG-7-app-no-firebase.md` · **Data:** 07/08/2026
**Plano:** `docs/testes/MG-7-plano.md` · **Ambiente:** emuladores.
Suíte do Firebase: **188** casos, 14 arquivos.

**Resultado: ✅ as três frentes executadas.** 188 casos no servidor, **244** de
widget no app (nenhum precisou mudar de asserção) e **4 em device**, contra os
emuladores e a fonte oficial de verdade. Fica aberta apenas a `FE-6` — remover o
`ApiClient` —, que depende de `MG-8` verificar a paridade antes de queimar a
ponte de volta.

## A lacuna que apareceu ao começar

`MG-2` modelou as coleções e escreveu as rules que **protegem** cada uma. `MG-3`
fechou o palpite. `MG-5` apura, `MG-6` sincroniza. Ao ir ligar o app, faltava o
óbvio: **ninguém tinha escrito quem escreve.** Não havia como criar bolão, gerar
convite, entrar por ele, sair ou trocar de papel.

Não é falha de nenhuma das stories — cada uma entregou o que dizia. É a costura
entre elas, e ela apareceu justamente onde tinha de aparecer: na primeira vez que
alguém tentou usar o modelo para alguma coisa. As tasks entraram nesta story como
`BE-1` a `BE-4`, porque é aqui que a falta aparece.

## Por que callable, e por que **não** para palpitar

Cada uma dessas operações escreve em mais de um lugar de uma vez (bolão +
vínculo + índice) e depende de dado que o cliente não pode ver: o hash do
convite, os jogos da fonte oficial. Rule não faz transação nem busca na internet.

O palpite ficou de fora de propósito. É o caminho quente do produto — todo mundo
palpita na mesma meia hora — e uma Function no meio somaria **cold start** ao
momento de maior concorrência. Ele continua sendo escrita direta com rule
(`MG-3`), que é o que o `PRD` pediu em requisito não funcional.

## O índice virou gatilho, não responsabilidade de cada callable

`usuarios/{uid}/boloes/{poolId}` é o que faz a tela inicial custar uma consulta.
Entrada, saída, papel, encerramento e arquivamento são **cinco portas** para o
mesmo estado. Se cada uma tivesse de lembrar de atualizar o índice, a primeira
que esquecesse sumiria com o bolão da tela de alguém — sem erro nenhum, e só
reclamando é que se descobriria.

Com gatilho é uma porta só: mudou o vínculo, reescreve o índice. O `MG-7-25`
(renomear) e o `MG-7-26` (encerrar) existem para provar que a propagação chega —
eles falhariam se o índice fosse escrito só na criação.

A contagem de ativos é **recontada, não incrementada**. `increment` erra em
silêncio quando o gatilho roda duas vezes, e o Firestore garante *at-least-once*.
O agregado `count()` cobra por resultado, não por documento lido, então recontar
custa quase o mesmo e não acumula erro.

## Uma guarda que nunca dispara — e por que ela fica

`MG-7-21` nasceu para verificar "o último admin não sai antes de promover outro"
e virou outra coisa ao ser escrito: **não há como montar o cenário.** O criador é
admin permanente, não sai, não é removido e não é rebaixado — então sempre sobra
um admin, e a guarda de `sair()` nunca chega a disparar.

O caso ficou afirmando o que é verdade (o segundo admin sai, o criador fica) e
registrando que a guarda é rede, não caminho. Apagá-la seria confiar que a regra
do criador permanente nunca vai mudar; um caso de teste que documenta por que ela
não dispara vale mais que um comentário.

## Duas decisões de integridade que o Postgres dava de graça

- **O calendário é global.** `jogos/{externalId}` serve a todos os bolões do
  campeonato, e criar bolão escreve **só o que falta**. Sobrescrever apagaria o
  placar que a sincronização já trouxe — e com ele a apuração de outro bolão.
  É o `MG-7-07`, herdeiro do `CC-1-11`.
- **A entrada fecha no início do primeiro jogo, recalculado a cada checagem.**
  Guardar um `firstKickoffAt` no bolão seria uma leitura a menos e um defeito a
  mais: jogo adiado muda o horário (`PP-6`), e o valor congelado manteria a
  entrada fechada por causa de um jogo que nem vai acontecer naquele horário.
  `MG-7-15` cobre o caso real — link mandado no grupo, aberto no dia seguinte.

## O que falta para fechar a story

Só a `FE-6` — remover o `ApiClient` HTTP. Ela fica **de propósito** para depois de
`MG-8`: enquanto a paridade não é verificada, poder voltar para o backend antigo
por `--dart-define=BACKEND=http` é o que separa "trocamos de backend" de "ficamos
sem backend".

---

## Segunda leva: regras, ciclo de vida, correção e extrato

Com a participação de pé, faltava o resto do que o app chama. Entraram as
callables de regras (`CC-3`), ciclo de vida (`CV-1`/`CV-3`/`CV-6`/`CV-7`),
correção manual (`PR-10`), extrato (`PR-2`) e ranking da rodada (`PR-4`) — que
são também as tasks `BE-4`, `BE-5` e `BE-6` que faltavam em `MG-5`.

### Um furo do `CC-3` que só apareceu ao ligar o app

A rule de `MG-2` deixava o admin escrever direto no documento do bolão, e o caso
`MG-2-07` afirmava isso como comportamento correto. Ao montar a alteração de
regras pelo app, ficou claro que era um furo: **rule não consegue olhar se já
existe palpite no bolão**, então ela permitia trocar a tabela de pontos *depois*
do primeiro palpite — exatamente o que a trava existe para impedir. Com a tabela
aberta, bastaria esperar os palpites e ajustar a pontuação para o que o palpite
do admin acerta; o sigilo de `PP-7` não cobre isso.

A escrita direta no bolão passou a ser negada por inteiro, e o `MG-2-07`
**inverteu**: hoje ele afirma que nem o admin altera. Vale para `closedAt` e
`archivedAt` pelo mesmo motivo — encerrar exige não haver jogo pendente, e
"encerrado antecipadamente" precisa ficar marcado como tal.

### O que não foi portado, e por quê

O encerramento antecipado do NestJS empurrava o `kickoffAt` dos jogos abertos
para a hora do encerramento, fechando o prazo. **Aqui isso seria um defeito:** o
jogo é global, compartilhado por todos os bolões do campeonato, e mexer no
horário fecharia o prazo dos outros junto. Não é preciso — a rule do palpite já
recusa bolão com `closedAt`. O `MG-7-48` verifica que o horário do jogo continua
intacto depois de encerrar.

### A consolidação do aviso mudou de mecanismo

`PR-7` pede aviso consolidado: vários jogos corrigidos na mesma sincronização
viram **uma** mensagem, porque um aviso por jogo transforma o domingo à noite em
spam — e quem desliga a notificação por spam deixa de ver a que importa.

No NestJS a sincronização passava um coletor em memória e gravava tudo junto no
fim. **Esse coletor não existe em Functions:** cada jogo corrigido é uma
invocação separada, possivelmente em outra máquina, e nenhuma sabe das outras. A
consolidação virou **fusão**: aviso de correção não lido e recente para o mesmo
bolão recebe o jogo novo em vez de virar outro. O que o participante vê é o
mesmo; o mecanismo deixou de depender de tudo acontecer no mesmo processo.

### Uma decisão que a tela vai sentir

A correção manual **não devolve quantos avisos saíram**. No NestJS ela apurava em
seguida e sabia; aqui quem apura é gatilho, e esperar por ele seria segurar o
admin numa tela por causa de um número. A callable devolve quantos palpites serão
reapurados — que é o que dá para afirmar com honestidade — e o texto da tela
precisa acompanhar isso quando `FE-2` chegar.

### Três testes meus, três defeitos meus

Nenhum era do produto:

1. **`MG-7-62` esperava pela categoria, não pelo efeito.** A condição era "existe
   aviso de placar", e Ana já tinha um de uma correção anterior: a espera
   terminava na hora e o caso seguia sem nunca ter esperado. Terceira vez que
   isso aparece (`MG-5-01`, `MG-6-03`) e a terceira forma diferente.
2. **`MG-7-63` esperava pelo ponto para depois exigir fusão.** O ponto é escrito
   **antes** da classificação e do aviso, então a segunda correção chegava antes
   de existir o que fundir — o teste acusava de defeito uma corrida que ele
   mesmo criava.
3. **`MG-7-64` testava o cenário errado.** "Quem não mudou nada não recebe aviso"
   é impossível de montar num bolão pequeno: qualquer correção mexe na
   **posição** de todo mundo, e aí todos são avisados com razão. Passou a usar um
   bolão com líder folgado, onde a mudança de um não altera a posição do outro.

E um quarto, de vizinhança: **o calendário é global**, então dois arquivos de
teste usando `BSA` na mesma rodada disputam os mesmos jogos — um jogo com horário
no passado deixado por outro arquivo fazia este recusar convite com "o bolão já
começou". Cada arquivo passou a ter o seu código de campeonato. É a mesma classe
do banco compartilhado entre dev e teste, resolvida em 06/08 no NestJS.

---

## Terceira leva: o app

### A troca não vazou para a apresentação — e dá para provar

Os **235 testes de widget não mudaram de asserção**. O que mudou neles foi uma
palavra por arquivo: o falso passou a estender `XRepositoryHttp` em vez de
`XRepository`, porque o nome de sempre virou **contrato** e ganhou duas
implementações. Nenhuma tela, nenhum estado, nenhuma mensagem.

O caminho para isso foi manter os **modelos** (`BolaoResumo`, `Rodada`,
`LinhaRanking`, `Extrato`) exatamente como estavam. Eles são o formato que a tela
conhece; enquanto o Firestore for traduzido para eles na camada de dados, a troca
para na fronteira. Foi por isso que `MG-2` gravou os campos em inglês.

### Um dono só para o offline

A story avisava: "o cache offline muda de dono — decidir **um** e registrar por
quê". O Firestore traz persistência local, e ela é boa, mas responde dado antigo
**sem dizer que é antigo**. O cache do app grava a idade junto e a mostra ("de 3 h
atrás"), porque dado velho sem rótulo é lido como dado de agora — e aí o
participante briga com quem tem o número certo.

Persistência do Firestore **desligada**. O offline continua sendo o `CacheLocal`,
que rotula. Ter os dois seria ter duas respostas para a mesma pergunta, uma delas
sem etiqueta.

### O relógio virou desvio, não instante

No HTTP a resposta da rodada trazia `serverTime` em toda chamada. No Firestore a
leitura não passa por servidor nenhum, então o horário vem de uma callable
chamada **uma vez por sessão**, e o app guarda a **diferença** — descontando meia
viagem de rede, senão o desvio embute a latência. Falhar não derruba nada: sem
medida a contagem cai no relógio do aparelho, e quem recusa de verdade continua
sendo a rule, que compara com o relógio do Google.

### Quatro coisas que só o device mostrou

Os testes de widget e os 188 do servidor passavam com tudo isto quebrado:

1. **`Cleartext HTTP traffic to 10.0.2.2 not permitted`.** O Android bloqueia
   tráfego em claro desde a API 28, e nenhum teste de widget sai da máquina. A
   liberação entrou **só na variante de debug** e só para os endereços do
   emulador — abrir cleartext no app publicado trocaria um problema de
   desenvolvimento por um furo de verdade.
2. **`Please set a valid API key`.** O SDK Android valida o **formato** da chave
   (`AIza` + 35 caracteres) antes de qualquer chamada, mesmo apontando para o
   emulador, que não valida chave nenhuma. O valor de exemplo tinha 40 caracteres
   e as Functions não subiam.
3. **Quem se cadastrava como "Bruno" virava "bruno1786151672" no bolão.** O
   gatilho de perfil dispara na criação da conta, quando o `displayName` do Auth
   ainda está vazio — ele só é preenchido no passo seguinte —, e caía no trecho
   antes do arroba. O app passou a gravar o nome no perfil com `merge`, o que
   resolve a corrida nos dois sentidos.
4. **A tela do extrato quebrava em vez de mostrar erro.** Ela só tratava
   `ApiError`; qualquer outra falha escapava, o estado ficava sem dado e sem erro,
   e o `build` tentava desenhar um extrato nulo. Falha inesperada agora também
   vira tela.

### Dois defeitos do meu próprio teste em device

- **Ele herdava a sessão da execução anterior.** O Firebase persiste o login no
  aparelho, então a segunda rodada em diante começava logada — e passava por um
  motivo que não era o dela. Agora começa deslogando.
- **`MG-7-80` pulava sozinho.** Ele dependia de sobrar um jogo já começado na
  rodada corrente e se auto-pulava quando não havia. Caso que pula sozinho não
  protege nada: passou a criar um bolão sobre uma rodada **passada**, onde o
  cenário do apito é certo.

### A cota da fonte aparece quando se roda em série

Rodar o arquivo várias vezes no mesmo minuto faz a criação de bolão falhar com
"não foi possível carregar os jogos do campeonato agora". **Não é defeito:** é o
adaptador recusando antes de gastar a cota de 10 requisições por minuto, que é o
comportamento de `PP-10`. O teste passou a ler os campeonatos uma vez só por
execução; o resto é esperar o minuto virar.

### O que ficou registrado para `MG-8`

- A **paginação do ranking** não tem `offset` no Firestore. Enquanto o contrato da
  tela for por deslocamento, a página seguinte lê do começo e descarta o que já
  mostrou. Aceitável com dezenas de participantes; trocar por cursor é outra
  story, não uma migração que promete não mexer na apresentação.
- A **correção manual** deixou de devolver o número de avisos, e o texto da tela
  fala em palpites reapurados.
