Caminho automático nasce sem os efeitos colaterais do caminho manual que ele espelha
O que aconteceu
Membership#automatic_approve! foi escrito em 12/08/2026 como o espelho de manual_approve!: grava card_status, admin_approved_at e admin_approved_by. Copiou o que estava no corpo do método e não o que o fluxo manual fazia em volta — entre outras coisas, o card_picture.attach da foto do perfil, que manual_approve!, AdminMembershipsService#admin_approve_membership e o form do Admin todos executam.
Nada quebrou no deploy. O efeito só apareceu na área pública do site do CITRG, semanas depois: a carteira digital das renovações auto-aprovadas saía com photo_url: null. Api::V2::TherapistController#card resolve a filiação por Membership.active_current, que pega a mais nova — ou seja, justamente a renovação auto_issued, a única sem foto. A filiação anterior, aprovada à mão, tinha a foto e nunca era servida.
Cerca de 100 filiações acumularam o defeito antes de alguém reparar.
Causa raiz
O espelhamento foi feito por semelhança de assinatura, não por equivalência de efeito. Os dois métodos têm o mesmo nome, os mesmos parâmetros e a mesma forma; um faz menos que o outro e nada no código diz isso.
Três coisas ajudaram a esconder:
- O efeito faltante é uma escrita satélite, não um campo do
update!. Um atributo a menos noupdate!salta aos olhos na revisão lado a lado. Umattachque acontece três linhas antes, não. - O caminho automático não tem testemunha. A aprovação manual tem um humano olhando a tela; a automática roda no webhook de pagamento e ninguém confere o resultado. Falha silenciosa em caminho sem operador só aparece pela reclamação do usuário final.
- Quem lê escolhe o registro mais novo.
active_currentpegava a filiação criada por último, que é exatamente a defeituosa. Se lesse a mais antiga, o bug ficaria invisível por mais tempo ainda — a leitura mascarava ou expunha o defeito por acidente, não por decisão.
Correção
O attach virou um método privado único, attach_profile_picture_to_card, chamado pelos dois ramos. Não há mais “a versão do manual” e “a versão do automático” — há um comportamento com dois chamadores.
As filiações já afetadas foram corrigidas no dado (anexando o blob da foto do perfil já existente), e não com fallback de leitura no serializer: a carteira continua exibindo o card_picture da própria filiação ou nada. Esconder o passivo na leitura teria deixado o defeito de escrita vivo.
Como evitar
- Quando um método novo é descrito como “o espelho de X”, extrair o comportamento compartilhado para um lugar só no mesmo commit. Espelho mantido por cópia diverge; a única pergunta é quando.
- Comparar os caminhos pelo efeito no banco, não pelo corpo do método: listar o que cada um escreve, incluindo anexos, callbacks e registros satélites. O que o manual faz em volta conta tanto quanto o que ele faz dentro.
- Todo caminho automático merece um teste que afirme o estado final completo, não só o campo que motivou a mudança. Aqui bastava um
assert membership.card_picture.attached?. - Ao introduzir um caminho automático paralelo a um manual, varrer quem lê o resultado. Se a leitura escolhe “o mais recente”, o registro produzido pelo caminho novo é o que vai aparecer — e qualquer defeito dele é imediatamente público.
- Passivo gerado por escrita defeituosa se corrige no dado. Fallback no serializer conserta a tela e preserva o bug.