/** * Promemoria evento che non ricompaiono (M23, DD-037): `bun test/integration/promemoria-eventi.test.ts`. * * Prima di M23 l'unica traccia che un promemoria fosse già stato generato era la riga in * `notifiche_utente`: eliminarla dal centro notifiche faceva rigenerare il promemoria (e * riaccodare la mail) al giro successivo del job. Qui si verifica che il registro * `promemoria_eventi_generati` lo eviti, senza rompere la generazione normale. * * Gira solo sullo stack locale (`npx supabase start`). Usa eventi con il prefisso * `test-promemoria-eventi` e gli slot `g4`, `g5`, `g6` della rosa seed come convocati; non * serve un account collegato perché le notifiche si eliminano con la service role. */ import assert from "node:assert/strict"; import { statoLocale } from "../helpers/locale"; import { prova, riepilogo, salta } from "../helpers/prova"; const locale = statoLocale(); if (!locale) { salta("promemoria eventi", "stack locale non attivo (npx supabase start)"); riepilogo("promemoria-eventi"); } else { const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale; console.log(`promemoria eventi su ${URL_BASE}`); const PREFISSO = "test-promemoria-eventi"; const EVENTO = `${PREFISSO}-a`; const EVENTO_LONTANO = `${PREFISSO}-lontano`; const EVENTO_CASCATA = `${PREFISSO}-cascata`; const G4 = "g4"; const G5 = "g5"; const G6 = "g6"; const rest = (percorso: string, token: string, init?: RequestInit) => fetch(`${URL_BASE}/rest/v1/${percorso}`, { ...init, headers: { apikey: token === SERVIZIO ? SERVIZIO : ANON, Authorization: `Bearer ${token}`, "content-type": "application/json", ...(init?.headers ?? {}), }, }); async function leggi(percorso: string): Promise { const res = await rest(percorso, SERVIZIO); if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`); return (await res.json()) as T[]; } async function scrivi(percorso: string, metodo: string, corpo?: unknown) { const res = await rest(percorso, SERVIZIO, { method: metodo, body: corpo === undefined ? null : JSON.stringify(corpo), }); if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`); } async function cron(tipo: "evento_promemoria_24h" | "evento_promemoria_3h") { const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_promemoria_eventi`, { method: "POST", headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}`, "content-type": "application/json", }, body: JSON.stringify({ p_tipo: tipo, p_finestra: tipo === "evento_promemoria_24h" ? "24 hours" : "3 hours", }), }); if (!res.ok) throw new Error(`genera_promemoria_eventi: ${res.status} ${await res.text()}`); } /** Data e ora dell'inizio tra `ore` ore, nel fuso Europe/Rome (convenzione di `eventi_app`). */ function tra(ore: number): { data: string; ora: string } { const quando = new Date(Date.now() + ore * 60 * 60 * 1000); const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando); const ora = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", hour: "2-digit", minute: "2-digit", hour12: false, }).format(quando); return { data, ora }; } const notifiche = (evento: string, tipo: string) => leggi<{ id: string; giocatore_id: string; letta: boolean }>( `notifiche_utente?evento_id=eq.${evento}&tipo=eq.${tipo}&select=id,giocatore_id,letta&order=giocatore_id`, ); const registro = (evento: string, tipo?: string) => leggi<{ giocatore_id: string; tipo: string }>( `promemoria_eventi_generati?evento_id=eq.${evento}${tipo ? `&tipo=eq.${tipo}` : ""}&select=giocatore_id,tipo`, ); const inCoda = async (ids: string[]) => ids.length === 0 ? [] : leggi<{ notifica_id: string }>( `notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`, ); try { await scrivi("eventi_app", "POST", { id: EVENTO, tipo: "allenamento", titolo: `${PREFISSO} evento`, ...tra(2), convocati: [G4, G5], }); await scrivi("eventi_app", "POST", { id: EVENTO_LONTANO, tipo: "allenamento", titolo: `${PREFISSO} lontano`, ...tra(72), convocati: [G4], }); await prova("il job genera il promemoria per i convocati e lo registra", async () => { await cron("evento_promemoria_24h"); const n = await notifiche(EVENTO, "evento_promemoria_24h"); assert.deepEqual( n.map((r) => r.giocatore_id), [G4, G5], ); assert.equal((await registro(EVENTO, "evento_promemoria_24h")).length, 2); }); await prova("un secondo giro del job non duplica né riaccoda", async () => { const prima = await notifiche(EVENTO, "evento_promemoria_24h"); const codaPrima = await inCoda(prima.map((r) => r.id)); await cron("evento_promemoria_24h"); const dopo = await notifiche(EVENTO, "evento_promemoria_24h"); assert.deepEqual( dopo.map((r) => r.id), prima.map((r) => r.id), ); assert.equal((await inCoda(dopo.map((r) => r.id))).length, codaPrima.length); }); await prova( "eliminata la notifica, il promemoria NON ricompare al giro successivo", async () => { const [mia] = await notifiche(EVENTO, "evento_promemoria_24h"); assert.ok(mia); await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE"); assert.equal((await notifiche(EVENTO, "evento_promemoria_24h")).length, 1); await cron("evento_promemoria_24h"); await cron("evento_promemoria_24h"); const dopo = await notifiche(EVENTO, "evento_promemoria_24h"); assert.equal(dopo.length, 1, "solo la notifica dell'altro convocato, non quella eliminata"); assert.ok(!dopo.some((r) => r.giocatore_id === mia.giocatore_id)); }, ); await prova("nessuna nuova mail in coda per il promemoria eliminato", async () => { const righe = await leggi<{ id: string; giocatore_id: string }>( `notifiche_utente?evento_id=eq.${EVENTO}&tipo=eq.evento_promemoria_24h&select=id,giocatore_id`, ); const coda = await inCoda(righe.map((r) => r.id)); assert.equal(coda.length, righe.length, "una sola riga in coda per notifica rimasta"); }); await prova("il promemoria a 3 ore è indipendente da quello a 24 ore", async () => { await cron("evento_promemoria_3h"); const n = await notifiche(EVENTO, "evento_promemoria_3h"); assert.equal(n.length, 2, "l'evento parte tra 2 ore: rientra nella finestra di 3"); const [mia] = n; assert.ok(mia); await scrivi(`notifiche_utente?id=eq.${mia.id}`, "DELETE"); await cron("evento_promemoria_3h"); assert.equal((await notifiche(EVENTO, "evento_promemoria_3h")).length, 1); }); await prova("un convocato aggiunto dopo riceve comunque il suo promemoria", async () => { await scrivi(`eventi_app?id=eq.${EVENTO}`, "PATCH", { convocati: [G4, G5, G6] }); await cron("evento_promemoria_24h"); const n = await notifiche(EVENTO, "evento_promemoria_24h"); assert.ok( n.some((r) => r.giocatore_id === G6), "g6 ha ricevuto il promemoria", ); assert.equal(n.filter((r) => r.giocatore_id === G6).length, 1); }); await prova("un evento fuori dalla finestra non genera né registra nulla", async () => { await cron("evento_promemoria_24h"); await cron("evento_promemoria_3h"); assert.equal((await notifiche(EVENTO_LONTANO, "evento_promemoria_24h")).length, 0); assert.equal((await registro(EVENTO_LONTANO)).length, 0); }); await prova("il registro non si vede né si scrive da un client", async () => { const lettura = await rest("promemoria_eventi_generati?select=*", ANON); assert.ok(!lettura.ok, "anon non può leggere il registro"); const inserimento = await rest("promemoria_eventi_generati", ANON, { method: "POST", body: JSON.stringify({ giocatore_id: G4, evento_id: EVENTO, tipo: "evento_promemoria_3h" }), }); assert.ok(!inserimento.ok, "anon non può scrivere nel registro"); }); await prova("cancellare l'evento toglie anche il registro", async () => { const { data, ora } = { data: tra(2).data, ora: tra(2).ora }; await scrivi("eventi_app", "POST", { id: EVENTO_CASCATA, tipo: "partita", titolo: `${PREFISSO} cascata`, data, ora, convocati: [G4], }); await cron("evento_promemoria_24h"); assert.equal((await registro(EVENTO_CASCATA)).length, 1); await scrivi(`eventi_app?id=eq.${EVENTO_CASCATA}`, "DELETE"); assert.equal((await registro(EVENTO_CASCATA)).length, 0); }); } finally { for (const id of [EVENTO, EVENTO_LONTANO, EVENTO_CASCATA]) { await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" }); } riepilogo("promemoria-eventi"); } }