process.env.THROTTLE_LOGIN_LIMIT = '1000';

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { SyncService } from '../src/sync/sync.service';
import { FONTE_DADOS } from '../src/fonte-dados/fonte-dados.port';
import { FonteFakeAdapter } from '../src/fonte-dados/fonte-fake.adapter';
import type {
  FonteDeDados,
  JogoExterno,
} from '../src/fonte-dados/fonte-dados.port';

/**
 * Execução do plano `docs/testes/PP-6-plano.md`.
 *
 * A fonte é **substituída por um stub controlável**: para verificar a
 * reabertura é preciso mandar o mesmo jogo com horário diferente, o que o
 * adaptador falso (determinístico) não permite. Os testes que existiam alteravam
 * o banco direto e por isso verificavam a regra de prazo, não o job.
 */
class FonteControlada implements FonteDeDados {
  private readonly base = new FonteFakeAdapter();

  /** Alterações a aplicar sobre o que a fonte "real" devolveria. */
  ajustes = new Map<string, Partial<JogoExterno>>();

  listarCompeticoes() {
    return this.base.listarCompeticoes();
  }

  async listarJogos(code: string, de: number, ate: number) {
    const jogos = await this.base.listarJogos(code, de, ate);
    return jogos.map((j) => {
      const ajuste = this.ajustes.get(j.externalId);
      return ajuste ? { ...j, ...ajuste } : j;
    });
  }
}

const RODADA = 29;

describe('PP-6 — reabertura automática por mudança de horário (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let sync: SyncService;
  const fonte = new FonteControlada();

  let admin: { id: string; token: string };
  let membro: { id: string; token: string };
  let poolId: string;
  let jogo: { id: string; externalId: string; kickoffAt: Date };

  const criarConta = async (email: string, nome: string) => {
    const conta = await request(app.getHttpServer())
      .post('/auth/register')
      .send({ email, password: 'senhaSegura123', displayName: nome })
      .expect(201);
    const login = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email, password: 'senhaSegura123' })
      .expect(200);
    return {
      id: (conta.body as { id: string }).id,
      token: (login.body as { accessToken: string }).accessToken,
    };
  };

  const palpitar = (token: string, matchId: string, casa: number, fora: number) =>
    request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${token}`)
      .send({ predictions: [{ matchId, homeScore: casa, awayScore: fora }] });

  beforeAll(async () => {
    const mod = await Test.createTestingModule({ imports: [AppModule] })
      .overrideProvider(FONTE_DADOS)
      .useValue(fonte)
      .compile();
    app = mod.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
    await app.init();
    await app.listen(0);
    prisma = app.get(PrismaService);
    sync = app.get(SyncService);

    admin = await criarConta('pp6-admin@e2e.teste', 'Admin');
    membro = await criarConta('pp6-membro@e2e.teste', 'Membro');

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão reabertura',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
      })
      .expect(201);
    poolId = (pool.body as { id: string }).id;
    await prisma.poolMember.create({
      data: { poolId, userId: membro.id, role: 'MEMBER' },
    });

    const v = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: {
        match: { select: { id: true, externalId: true, kickoffAt: true } },
      },
    });
    jogo = v.match;
  });

  afterEach(() => fonte.ajustes.clear());

  afterAll(async () => {
    await prisma.match.deleteMany({
      where: { competitionCode: 'BSA', matchday: RODADA },
    });
    await prisma.pool.deleteMany({ where: { createdById: admin.id } });
    await prisma.user.deleteMany({ where: { email: { contains: 'pp6-' } } });
    await app.close();
  });

  /** Coloca o jogo no passado e com palpite salvo, como antes de um adiamento. */
  const fecharComPalpite = async () => {
    await prisma.match.update({
      where: { id: jogo.id },
      data: { kickoffAt: new Date(Date.now() - 60 * 60 * 1000) },
    });
    await prisma.prediction.upsert({
      where: {
        poolId_matchId_userId: {
          poolId,
          matchId: jogo.id,
          userId: membro.id,
        },
      },
      create: {
        poolId,
        matchId: jogo.id,
        userId: membro.id,
        homeScore: 1,
        awayScore: 1,
      },
      update: { homeScore: 1, awayScore: 1, points: null, band: null, scoredAt: null },
    });
  };

  it('PP-6-01 adiamento para o futuro reabre o palpite', async () => {
    await fecharComPalpite();
    // Confirma que estava fechado.
    const antes = await palpitar(membro.token, jogo.id, 2, 0).expect(201);
    expect(
      (antes.body as { results: { reason: string | null }[] }).results[0].reason,
    ).toBe('PRAZO_FECHADO');

    const novaData = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: novaData,
      status: 'TIMED',
      golsCasa: null,
      golsFora: null,
    });

    const r = await sync.sincronizar();
    expect(r.horariosAlterados).toBeGreaterThan(0);
    expect(r.palpitesReabertos).toBeGreaterThan(0);

    // Agora aceita.
    const depois = await palpitar(membro.token, jogo.id, 2, 0).expect(201);
    expect((depois.body as { savedCount: number }).savedCount).toBe(1);
  });

  it('PP-6-02 o palpite anterior continua valendo depois de reabrir', async () => {
    await fecharComPalpite();
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
      status: 'TIMED',
    });
    await sync.sincronizar();

    const palpite = await prisma.prediction.findFirstOrThrow({
      where: { poolId, matchId: jogo.id, userId: membro.id },
      select: { homeScore: true, awayScore: true },
    });
    // Reabrir não é apagar: quem não quiser mexer não perde o que tinha.
    expect(palpite.homeScore).toBe(1);
    expect(palpite.awayScore).toBe(1);
  });

  it('PP-6-03 reabertura não revela palpite de ninguém', async () => {
    await fecharComPalpite();
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
      status: 'TIMED',
    });
    await sync.sincronizar();

    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo.id}/predictions`)
      .set('Authorization', `Bearer ${membro.token}`)
      .expect(200);

    // Jogo adiado não terminou: revelar aqui daria ao repalpitador o palpite
    // dos outros.
    expect((res.body as { revealed: boolean }).revealed).toBe(false);
  });

  it('PP-6-05 antecipação do horário fecha o prazo mais cedo', async () => {
    // Prazo aberto: kickoff no futuro.
    await prisma.match.update({
      where: { id: jogo.id },
      data: { kickoffAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) },
    });

    fonte.ajustes.set(jogo.externalId, {
      inicioEm: new Date(Date.now() - 60 * 60 * 1000),
      status: 'IN_PLAY',
    });
    const r = await sync.sincronizar();
    expect(r.horariosAlterados).toBeGreaterThan(0);
    // Antecipar não é reabrir.
    expect(r.palpitesReabertos).toBe(0);

    const res = await palpitar(membro.token, jogo.id, 3, 3).expect(201);
    expect(
      (res.body as { results: { reason: string | null }[] }).results[0].reason,
    ).toBe('PRAZO_FECHADO');
  });

  it('PP-6-06 adiamento dentro do passado não reabre nada', async () => {
    await fecharComPalpite();
    const novoPassado = new Date(Date.now() - 30 * 60 * 1000);
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: novoPassado,
      status: 'POSTPONED',
      golsCasa: null,
      golsFora: null,
    });

    const r = await sync.sincronizar();
    expect(r.horariosAlterados).toBeGreaterThan(0);
    // Só reabre quando o horário novo está no futuro.
    expect(r.palpitesReabertos).toBe(0);

    const res = await palpitar(membro.token, jogo.id, 0, 0).expect(201);
    expect(
      (res.body as { results: { reason: string | null }[] }).results[0].reason,
    ).toBe('PRAZO_FECHADO');
  });

  it('PP-6-08 sincronizar duas vezes sem mudança não gera evento', async () => {
    await sync.sincronizar();
    const segunda = await sync.sincronizar();

    // Job de hora em hora não pode produzir evento a cada volta.
    expect(segunda.horariosAlterados).toBe(0);
    expect(segunda.palpitesReabertos).toBe(0);
    expect(segunda.placaresAlterados).toBe(0);
    expect(segunda.correcoesAplicadas).toBe(0);
  });

  it('PP-6-09 adiamento e placar novo na mesma sincronização', async () => {
    // Jogo apurado com placar, que a fonte passa a informar com outro placar
    // **e** outro horário.
    await prisma.match.update({
      where: { id: jogo.id },
      data: {
        kickoffAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
        status: 'FINISHED',
        homeScore: 1,
        awayScore: 0,
      },
    });

    const novaData = new Date(Date.now() - 30 * 60 * 1000);
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: novaData,
      status: 'FINISHED',
      golsCasa: 2,
      golsFora: 2,
    });

    const r = await sync.sincronizar();
    // Placar mudou num jogo que já tinha placar: vai pelo caminho de correção.
    expect(r.correcoesAplicadas).toBeGreaterThan(0);
    expect(r.horariosAlterados).toBeGreaterThan(0);

    const depois = await prisma.match.findUniqueOrThrow({
      where: { id: jogo.id },
      select: { homeScore: true, awayScore: true, kickoffAt: true },
    });
    // As duas coisas aplicadas: aplicar só uma deixaria o jogo num estado que
    // nenhuma tela sabe descrever.
    expect(depois.homeScore).toBe(2);
    expect(depois.kickoffAt.getTime()).toBe(novaData.getTime());
  });

  it('PP-6-10 adiamento sem nova data não reabre e não trava o bolão', async () => {
    await fecharComPalpite();
    const mesmoHorario = await prisma.match.findUniqueOrThrow({
      where: { id: jogo.id },
      select: { kickoffAt: true },
    });

    // É o que a fonte faz quando a data nova ainda não existe: muda o status e
    // mantém o horário.
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: mesmoHorario.kickoffAt,
      status: 'POSTPONED',
      golsCasa: null,
      golsFora: null,
    });

    const r = await sync.sincronizar();
    expect(r.palpitesReabertos).toBe(0);

    // O encerramento normal recusa (jogo sem resultado)...
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/close`)
      .set('Authorization', `Bearer ${admin.token}`)
      .expect(409);

    // ...e o antecipado continua disponível como saída.
    const antecipado = await request(app.getHttpServer())
      .post(`/pools/${poolId}/close-early`)
      .set('Authorization', `Bearer ${admin.token}`)
      .expect(201);
    expect((antecipado.body as { closedEarly: boolean }).closedEarly).toBe(true);

    await prisma.pool.update({
      where: { id: poolId },
      data: { closedAt: null, closedEarly: false },
    });
  });

  it('PP-6-07 jogo já apurado que é adiado não fica com ponto órfão', async () => {
    // Caso escrito para **forçar a decisão**: se o palpite reabre e o ponto
    // antigo permanece, o participante repalpita já sabendo o resultado e a
    // pontuação dele deixa de ter relação com o palpite.
    await prisma.match.update({
      where: { id: jogo.id },
      data: {
        kickoffAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
        status: 'FINISHED',
        homeScore: 1,
        awayScore: 1,
      },
    });
    await prisma.prediction.upsert({
      where: {
        poolId_matchId_userId: {
          poolId,
          matchId: jogo.id,
          userId: membro.id,
        },
      },
      create: {
        poolId,
        matchId: jogo.id,
        userId: membro.id,
        homeScore: 1,
        awayScore: 1,
      },
      update: {
        homeScore: 1,
        awayScore: 1,
        points: null,
        band: null,
        scoredAt: null,
      },
    });

    const scoring = app.get(
      (await import('../src/scoring/scoring.service')).ScoringService,
    );
    await scoring.apurarPendentes();
    const apurado = await prisma.prediction.findFirstOrThrow({
      where: { poolId, matchId: jogo.id, userId: membro.id },
      select: { points: true },
    });
    expect(apurado.points).toBe(10);

    // A fonte passa a informar o jogo como adiado para o futuro, sem placar.
    fonte.ajustes.set(jogo.externalId, {
      inicioEm: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
      status: 'POSTPONED',
      golsCasa: null,
      golsFora: null,
    });
    const r = await sync.sincronizar();
    expect(r.palpitesReabertos).toBeGreaterThan(0);

    const depois = await prisma.prediction.findFirstOrThrow({
      where: { poolId, matchId: jogo.id, userId: membro.id },
      select: { points: true, band: true, scoredAt: true },
    });

    // Palpite reaberto **e** ponto zerado: manter o ponto antigo produziria um
    // valor sem relação nenhuma com o palpite que vai valer.
    expect(depois.points).toBeNull();
    expect(depois.band).toBeNull();
    expect(depois.scoredAt).toBeNull();
  });

  it('PP-6-07b quem perdeu ponto na reversão é avisado', async () => {
    // A queda de pontuação aqui não vem de correção de placar: vem de o
    // resultado ter deixado de existir. Descobrir isso por acaso é o pior
    // resultado possível (PR-7).
    const avisos = await prisma.notification.findMany({
      where: { userId: membro.id, kind: 'PONTO_REVERTIDO' },
      select: { title: true, body: true },
    });

    expect(avisos.length).toBeGreaterThan(0);
    expect(avisos[0].title).toBe('Um jogo voltou a ficar sem resultado');
    expect(avisos[0].body).toContain('retirou o placar');
    expect(avisos[0].body).toContain('10 ponto(s)');
  });

  it('PP-10-05 job parado recupera o atraso sem perder mudanças', async () => {
    // Plano docs/testes/PP-10-plano.md, caso PP-10-05. Duas mudanças acumuladas
    // no intervalo sem execução: o job não pode depender de ter visto o estado
    // intermediário.
    const vinculos = await prisma.poolMatch.findMany({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: {
        match: { select: { id: true, externalId: true } },
      },
      take: 2,
    });
    const [a, b] = vinculos.map((v) => v.match);

    // Estado "antigo": os dois com horário no passado e placar lançado.
    await prisma.match.updateMany({
      where: { id: { in: [a.id, b.id] } },
      data: {
        kickoffAt: new Date(Date.now() - 3 * 60 * 60 * 1000),
        status: 'FINISHED',
        homeScore: 1,
        awayScore: 0,
      },
    });

    const novaData = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000);
    // Mudança 1: `a` foi adiado. Mudança 2: `b` teve o placar corrigido.
    fonte.ajustes.set(a.externalId, {
      inicioEm: novaData,
      status: 'TIMED',
      golsCasa: null,
      golsFora: null,
    });
    fonte.ajustes.set(b.externalId, {
      status: 'FINISHED',
      golsCasa: 3,
      golsFora: 2,
    });

    const r = await sync.sincronizar();

    const depoisA = await prisma.match.findUniqueOrThrow({
      where: { id: a.id },
      select: { kickoffAt: true, homeScore: true },
    });
    const depoisB = await prisma.match.findUniqueOrThrow({
      where: { id: b.id },
      select: { homeScore: true, awayScore: true },
    });

    // As **duas** aplicadas numa só volta.
    expect(depoisA.kickoffAt.getTime()).toBe(novaData.getTime());
    expect(depoisA.homeScore).toBeNull();
    expect(depoisB.homeScore).toBe(3);
    expect(depoisB.awayScore).toBe(2);
    expect(r.horariosAlterados).toBeGreaterThan(0);
    expect(r.correcoesAplicadas).toBeGreaterThan(0);
  });

  it('PP-10-08 jogo que a fonte não conhece mais permanece intacto', async () => {
    // Plano docs/testes/PP-10-plano.md, caso PP-10-08. Apagar levaria embora os
    // palpites de todos por causa de uma falha da fonte.
    const alvo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'desc' } },
      select: { match: { select: { id: true, externalId: true } } },
    });

    await prisma.match.update({
      where: { id: alvo.match.id },
      data: { status: 'FINISHED', homeScore: 4, awayScore: 1 },
    });

    // A fonte deixa de devolver este jogo: o stub filtra pelo externalId.
    const originalListar = fonte.listarJogos.bind(fonte);
    fonte.listarJogos = async (code, de, ate) => {
      const jogos = await originalListar(code, de, ate);
      return jogos.filter((j) => j.externalId !== alvo.match.externalId);
    };

    const antes = await prisma.match.count();
    await sync.sincronizar();

    const depois = await prisma.match.findUniqueOrThrow({
      where: { id: alvo.match.id },
      select: { homeScore: true, awayScore: true, status: true },
    });
    expect(await prisma.match.count()).toBe(antes);
    expect(depois.homeScore).toBe(4);
    expect(depois.status).toBe('FINISHED');

    fonte.listarJogos = originalListar;
  });

  it('PP-10-11 duas sincronizações concorrentes não duplicam efeito', async () => {
    // Plano docs/testes/PP-10-plano.md, caso PP-10-11.
    const alvo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { match: { select: { id: true, externalId: true } } },
    });

    await prisma.match.update({
      where: { id: alvo.match.id },
      data: {
        status: 'FINISHED',
        homeScore: 1,
        awayScore: 1,
        kickoffAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
      },
    });
    await prisma.scoreCorrection.deleteMany({
      where: { matchId: alvo.match.id },
    });
    fonte.ajustes.set(alvo.match.externalId, {
      status: 'FINISHED',
      golsCasa: 2,
      golsFora: 1,
    });

    const [a, b] = await Promise.all([
      sync.sincronizar(),
      sync.sincronizar(),
    ]);

    // Uma correção, não duas. Antes de 07/08 isto passava **por sorte**: as duas
    // execuções liam o placar antigo ao mesmo tempo e as duas gravavam correção,
    // deixando o histórico do jogo com a mesma alteração duplicada e o aviso de
    // recálculo saindo duas vezes. Falhava em ~1 de 4 execuções da suíte.
    const correcoes = await prisma.scoreCorrection.count({
      where: { matchId: alvo.match.id },
    });
    expect(correcoes).toBe(1);

    // E a segunda chamada diz que não fez nada, em vez de devolver os números da
    // primeira — somar duas vezes mentiria no relatório do job.
    const ignoradas = [a, b].filter((r) => r.ignorada);
    expect(ignoradas).toHaveLength(1);
    expect(ignoradas[0].correcoesAplicadas).toBe(0);
  });

  it('PP-10-09 jogo novo na fonte não entra no bolão já criado', async () => {
    // Plano docs/testes/PP-10-plano.md, caso PP-10-09. Os jogos do bolão são
    // fixados na criação (CC-1); acrescentar depois mudaria a competição no meio.
    const antesNoBolao = await prisma.poolMatch.count({ where: { poolId } });
    const antesTotal = await prisma.match.count();

    const originalListar = fonte.listarJogos.bind(fonte);
    fonte.listarJogos = async (code, de, ate) => {
      const jogos = await originalListar(code, de, ate);
      return [
        ...jogos,
        {
          externalId: 'jogo-inventado-pp10-09',
          competitionCode: 'BSA',
          rodada: RODADA,
          timeCasa: 'Time A',
          timeFora: 'Time B',
          inicioEm: new Date(Date.now() + 24 * 60 * 60 * 1000),
          status: 'TIMED' as const,
          golsCasa: null,
          golsFora: null,
          atualizadoEm: null,
        },
      ];
    };

    await sync.sincronizar();

    expect(await prisma.poolMatch.count({ where: { poolId } })).toBe(
      antesNoBolao,
    );
    expect(await prisma.match.count()).toBe(antesTotal);
    expect(
      await prisma.match.findFirst({
        where: { externalId: 'jogo-inventado-pp10-09' },
      }),
    ).toBeNull();

    fonte.listarJogos = originalListar;
  });

  it('PP-6 FE-2 quem tinha palpite é avisado da reabertura', async () => {
    // Reabertura silenciosa é pior que não reabrir: quem não fica sabendo mantém
    // o palpite antigo sem revisar, e descobre na apuração que jogou um placar
    // escolhido para outra data.
    await prisma.notification.deleteMany({ where: { userId: membro.id } });
    await fecharComPalpite();

    fonte.ajustes.set(jogo.externalId, {
      inicioEm: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000),
      status: 'TIMED',
      golsCasa: null,
      golsFora: null,
    });
    await sync.sincronizar();

    const avisos = await prisma.notification.findMany({
      where: { userId: membro.id, kind: 'JOGO_REABERTO' },
      select: { title: true, body: true },
    });
    expect(avisos.length).toBe(1);
    expect(avisos[0].title).toBe('Um jogo foi adiado e o palpite reabriu');
    // O palpite que continua valendo aparece no texto: é o que dispensa abrir o
    // app para conferir.
    expect(avisos[0].body).toContain('1x1');
    expect(avisos[0].body).toContain('dá para mudar');
  });

  it('PR-7-05 dois jogos corrigidos na mesma sincronização geram UM aviso', async () => {
    // Plano docs/testes/PR-7-plano.md. O critério é explícito: aviso
    // consolidado, não um por jogo. Uma rodada inteira corrigida mandava um
    // aviso por jogo — spam de domingo à noite, que é o que faz o participante
    // desligar a notificação e deixar de ver as que importam.
    const doisJogos = await prisma.poolMatch.findMany({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      take: 2,
      select: {
        match: { select: { id: true, externalId: true, kickoffAt: true } },
      },
    });

    // Estado inicial: os dois com placar antigo, já apurados.
    for (const v of doisJogos) {
      await prisma.match.update({
        where: { id: v.match.id },
        data: {
          status: 'FINISHED',
          homeScore: 0,
          awayScore: 0,
          kickoffAt: new Date(Date.now() - 3 * 60 * 60 * 1000),
        },
      });
    }
    // Palpites do membro nos dois jogos, para ele ser afetado pelas correções.
    for (const v of doisJogos) {
      await prisma.prediction.upsert({
        where: {
          poolId_matchId_userId: {
            poolId,
            matchId: v.match.id,
            userId: membro.id,
          },
        },
        create: {
          poolId,
          userId: membro.id,
          matchId: v.match.id,
          homeScore: 2,
          awayScore: 1,
        },
        update: { homeScore: 2, awayScore: 1, points: null, band: null, scoredAt: null },
      });
    }
    await prisma.notification.deleteMany({ where: { userId: membro.id } });
    await prisma.scoreCorrection.deleteMany({
      where: { matchId: { in: doisJogos.map((v) => v.match.id) } },
    });

    // A fonte passa a informar 2x1 nos dois: duas correções numa execução.
    for (const v of doisJogos) {
      fonte.ajustes.set(v.match.externalId, {
        status: 'FINISHED',
        golsCasa: 2,
        golsFora: 1,
      });
    }
    const r = await sync.sincronizar();
    expect(r.correcoesAplicadas).toBeGreaterThanOrEqual(2);

    const avisos = await prisma.notification.findMany({
      where: { userId: membro.id, kind: { startsWith: 'PLACAR_CORRIGIDO' } },
      select: { title: true, body: true },
    });

    // UM aviso, não dois.
    expect(avisos).toHaveLength(1);
    expect(avisos[0].title).toContain('2 jogos');
    // E ele diz **quais** jogos e o que mudou em cada um: aviso consolidado que
    // não diz o que mudou é pior que dois avisos.
    expect(avisos[0].body).toContain('0x0 → 2x1');
    expect(avisos[0].body.split(';')).toHaveLength(2);

    for (const v of doisJogos) fonte.ajustes.delete(v.match.externalId);
  });
});
