Implementa le notifiche email via Gmail da un worker Docker (DD-036)

Migration m22: coda notifiche_email_coda, preferenze_utente, trigger che
accoda ogni notifica di notifiche_utente e tre funzioni RPC riservate
alla service role. Worker in mailer/ (bun + nodemailer, solo polling in
uscita) con backoff, tetto giornaliero e healthcheck. Interruttore
«Email» in Profilo → Opzioni, acceso di default. Test unit e di
integrazione; documentazione aggiornata (DD-036 accettata).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 11:07:24 +02:00
1 parent 2a0b611459
commit b7d3fde225
24 files changed
+2138 -186

No files matched your search

+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker email in `mailer/` e preferenza «Email») | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Database locale in Docker
+571
View File
@@ -0,0 +1,571 @@
/**
* Notifiche email (M22, DD-036): `bun test/integration/notifiche-email.test.ts`.
*
* Copre il lato database del canale email: il trigger che accoda ogni notifica di
* `notifiche_utente`, le tre funzioni RPC del worker (`prendi_notifiche_email`,
* `esito_notifica_email`, `email_inviate_ultime_24h`), la preferenza per account e la RLS che
* tiene la coda lontana dai client. Il worker vero e la logica di invio sono coperti da
* `unit/mailer-core.test.ts`; qui non parte nessuna mail.
*
* Gira solo sullo stack locale (`npx supabase start`): usa gli slot `g4` (collegato a un
* account di prova) e `g5` (senza account) della rosa seed, con l'email impostata per la
* durata del test e ripristinata alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche email", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-email");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche email su ${URL_BASE}`);
const PREFISSO = "test-notifiche-email";
// Un evento per test che ne ha bisogno: (giocatore, evento, tipo) è UNIQUE.
const EVENTO_TIPI = `${PREFISSO}-tipi`;
const EVENTO_UPSERT = `${PREFISSO}-upsert`;
const EVENTO_PRESA = `${PREFISSO}-presa`;
const EVENTO_CRON = `${PREFISSO}-cron`;
const SLOT_COLLEGATO = "g4";
const SLOT_LIBERO = "g5";
const EMAIL_COLLEGATO = `${PREFISSO}-g4@example.test`;
const EMAIL_LIBERO = `${PREFISSO}-g5@example.test`;
const PASSWORD = "prova-notifiche-email-123";
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<T>(percorso: string, token = SERVIZIO): Promise<T[]> {
const res = await rest(percorso, token);
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, token: string, metodo: string, corpo: unknown) {
const res = await rest(percorso, token, { method: metodo, body: JSON.stringify(corpo) });
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
async function rpc<T = unknown>(nome: string, corpo: Record<string, unknown>, token = SERVIZIO) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
method: "POST",
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(corpo),
});
const testo = await res.text();
return { ok: res.ok, stato: res.status, dati: (testo ? JSON.parse(testo) : null) as T };
}
async function creaUtente(email: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
type Coda = {
notifica_id: string;
stato: string;
tentativi: number;
prossimo_tentativo: string;
errore: string | null;
inviata_il: string | null;
};
type Preso = {
id_notifica: string;
destinatario: string;
oggetto: string;
testo: string;
id_evento: string | null;
tipo_notifica: string;
tentativi_fatti: number;
};
const creaNotifica = async (
giocatoreId: string,
extra: Record<string, unknown> = {},
): Promise<string> => {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
giocatore_id: giocatoreId,
tipo: "admin",
titolo: `${PREFISSO} titolo`,
corpo: `${PREFISSO} corpo`,
...extra,
}),
});
if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`);
return ((await res.json()) as { id: string }[])[0]!.id;
};
const coda = async (id: string): Promise<Coda | undefined> =>
(await leggi<Coda>(`notifiche_email_coda?notifica_id=eq.${id}&select=*`))[0];
/** Prende il lotto e tiene solo le righe di questo test: la coda locale può avere altro. */
const prendi = async (mie: string[], max = 100): Promise<Preso[]> => {
const r = await rpc<Preso[]>("prendi_notifiche_email", { p_max: max });
assert.ok(r.ok, `prendi_notifiche_email: ${r.stato}`);
return r.dati.filter((p) => mie.includes(p.id_notifica));
};
const esito = async (
id: string,
tipo: string,
errore: string | null = null,
prossimo: string | null = null,
) => {
const r = await rpc("esito_notifica_email", {
p_id: id,
p_esito: tipo,
p_errore: errore,
p_prossimo: prossimo,
});
assert.ok(r.ok, `esito_notifica_email: ${r.stato}`);
};
const idUtenti: string[] = [];
const creati: string[] = [];
let tokenAdmin = "";
const originali = new Map<string, { email: string | null; auth_user_id: string | null }>();
try {
// --- preparazione: un admin, un giocatore collegato a g4, g5 senza account -----------
const emailAdmin = `${PREFISSO}-admin-${Date.now()}@example.test`;
const emailGiocatore = `${PREFISSO}-giocatore-${Date.now()}@example.test`;
const idAdmin = await creaUtente(emailAdmin);
const idGiocatore = await creaUtente(emailGiocatore);
idUtenti.push(idAdmin, idGiocatore);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: idAdmin, role: "admin" });
tokenAdmin = await accedi(emailAdmin);
const tokenGiocatore = await accedi(emailGiocatore);
for (const id of [SLOT_COLLEGATO, SLOT_LIBERO]) {
const [riga] = await leggi<{ email: string | null; auth_user_id: string | null }>(
`giocatori_squadra?id=eq.${id}&select=email,auth_user_id`,
);
originali.set(id, riga ?? { email: null, auth_user_id: null });
}
// Il trigger `enforce_giocatori_squadra_update` rifiuta la service key: serve l'admin.
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: EMAIL_COLLEGATO,
auth_user_id: idGiocatore,
});
await scrivi(`giocatori_squadra?id=eq.${SLOT_LIBERO}`, tokenAdmin, "PATCH", {
email: EMAIL_LIBERO,
});
for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA]) {
await scrivi("eventi_app", SERVIZIO, "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} evento`,
data: "2030-01-01",
ora: "20:00",
convocati: [],
});
}
// --- 1. Trigger ---------------------------------------------------------------------
await prova("una notifica nuova finisce in coda, in_coda e con zero tentativi", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
assert.equal(riga?.inviata_il, null);
});
await prova("ogni tipo di notifica esistente viene accodato", async () => {
for (const tipo of ["evento_promemoria_24h", "evento_promemoria_3h", "turno_palloni"]) {
const id = await creaNotifica(SLOT_COLLEGATO, { tipo, evento_id: EVENTO_TIPI });
creati.push(id);
assert.equal((await coda(id))?.stato, "in_coda", tipo);
}
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "sollecita_presenze",
evento_id: EVENTO_TIPI,
});
creati.push(id);
assert.equal((await coda(id))?.stato, "in_coda", "sollecita_presenze");
});
await prova("segnare come letta non rimette in coda una mail già inviata", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const [presa] = await prendi([id]);
assert.ok(presa);
await esito(id, "inviata");
await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "PATCH", { letta: true });
assert.equal((await coda(id))?.stato, "inviata");
});
await prova("il rinvio con upsert (creato_il riscritto) rimette in coda la mail", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
tipo: "turno_palloni",
evento_id: EVENTO_UPSERT,
});
creati.push(id);
await prendi([id]);
await esito(id, "inviata");
assert.equal((await coda(id))?.stato, "inviata");
// Come `promemoria-palloni.ts` e `sollecita-presenze.ts`: stesso (giocatore, evento, tipo).
const res = await rest("notifiche_utente?on_conflict=giocatore_id,evento_id,tipo", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
body: JSON.stringify({
giocatore_id: SLOT_COLLEGATO,
tipo: "turno_palloni",
titolo: `${PREFISSO} aggiornato`,
corpo: "nuovo testo",
evento_id: EVENTO_UPSERT,
letta: false,
creato_il: new Date().toISOString(),
}),
});
assert.ok(res.ok, `upsert: ${res.status}`);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
assert.equal(riga?.inviata_il, null);
});
await prova(
"il promemoria del cron accoda una volta sola, anche se il cron rigira",
async () => {
const adesso = new Date(Date.now() + 20 * 60 * 60 * 1000);
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso);
const ora = new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(adesso);
await scrivi("eventi_app", SERVIZIO, "POST", {
id: EVENTO_CRON,
tipo: "allenamento",
titolo: `${PREFISSO} cron`,
data,
ora,
convocati: [SLOT_COLLEGATO],
});
const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" };
await rpc("genera_promemoria_eventi", args);
const [notifica] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTO_CRON}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.ok(notifica, "il cron ha creato la notifica");
assert.equal((await coda(notifica.id))?.stato, "in_coda");
await prendi([notifica.id]);
await esito(notifica.id, "inviata");
await rpc("genera_promemoria_eventi", args);
assert.equal(
(await coda(notifica.id))?.stato,
"inviata",
"ON CONFLICT DO NOTHING: nessun rinvio",
);
},
);
await prova("cancellare la notifica (swipe) toglie anche la riga in coda", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
assert.ok(await coda(id));
await scrivi(`notifiche_utente?id=eq.${id}`, tokenGiocatore, "DELETE", undefined);
assert.equal(await coda(id), undefined);
});
// --- 2. prendi_notifiche_email ------------------------------------------------------
await prova("prendi restituisce destinatario e testo e marca la riga in_invio", async () => {
const id = await creaNotifica(SLOT_COLLEGATO, {
titolo: `${PREFISSO} oggetto`,
corpo: `${PREFISSO} testo`,
evento_id: EVENTO_PRESA,
tipo: "sollecita_presenze",
});
creati.push(id);
const [p] = await prendi([id]);
assert.ok(p, "la riga è stata presa");
assert.equal(p.destinatario, EMAIL_COLLEGATO);
assert.equal(p.id_evento, EVENTO_PRESA);
assert.equal(p.tipo_notifica, "sollecita_presenze");
assert.equal((await coda(id))?.stato, "in_invio");
});
await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0);
});
await prova("due chiamate concorrenti si dividono le righe senza doppioni", async () => {
const ids = await Promise.all(Array.from({ length: 6 }, () => creaNotifica(SLOT_COLLEGATO)));
creati.push(...ids);
const [a, b] = await Promise.all([prendi(ids, 3), prendi(ids, 3)]);
const tutti = [...a, ...b].map((p) => p.id_notifica);
assert.equal(tutti.length, new Set(tutti).size, "nessuna riga presa due volte");
assert.ok(tutti.length >= 3 && tutti.length <= 6);
// Le righe rimaste tornano prese al giro dopo, una sola volta ciascuna.
const resto = (await prendi(ids)).map((p) => p.id_notifica);
assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort());
});
await prova("slot senza account collegato: mail 'saltata', mai presa", async () => {
const id = await creaNotifica(SLOT_LIBERO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
const riga = await coda(id);
assert.equal(riga?.stato, "saltata");
assert.equal(riga?.errore, null);
});
await prova("slot collegato ma senza email: 'saltata'", async () => {
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: null,
});
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
await scrivi(`giocatori_squadra?id=eq.${SLOT_COLLEGATO}`, tokenAdmin, "PATCH", {
email: EMAIL_COLLEGATO,
});
});
await prova("la mail in coda viene ripresa dopo 10 minuti se il worker è morto", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0, "ancora in invio");
const vecchia = new Date(Date.now() - 11 * 60_000).toISOString();
await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", {
aggiornata_il: vecchia,
});
assert.equal((await prendi([id])).length, 1, "recuperata");
});
// --- 3. Preferenza per account ------------------------------------------------------
await prova("nessuna riga in preferenze_utente vuol dire mail attive", async () => {
const righe = await leggi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`);
assert.equal(righe.length, 0);
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
});
await prova("il giocatore spegne le email dal proprio account: 'saltata'", async () => {
const res = await rest("preferenze_utente?on_conflict=auth_user_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=minimal" },
body: JSON.stringify({ auth_user_id: idGiocatore, email_notifiche: false }),
});
assert.ok(res.ok, `upsert preferenza: ${res.status}`);
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
});
await prova("riacceso l'interruttore le mail nuove ripartono", async () => {
await scrivi(`preferenze_utente?auth_user_id=eq.${idGiocatore}`, tokenGiocatore, "PATCH", {
email_notifiche: true,
});
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
assert.equal((await prendi([id])).length, 1);
});
await prova("un giocatore non scrive né legge le preferenze di un altro account", async () => {
const res = await rest("preferenze_utente", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ auth_user_id: idAdmin, email_notifiche: false }),
});
assert.ok(!res.ok, "insert per un altro account rifiutato dalla RLS");
await scrivi("preferenze_utente", SERVIZIO, "POST", {
auth_user_id: idAdmin,
email_notifiche: false,
});
const viste = await leggi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore);
assert.equal(viste.length, 0, "la riga altrui è invisibile");
await scrivi(`preferenze_utente?auth_user_id=eq.${idAdmin}`, tokenGiocatore, "PATCH", {
email_notifiche: true,
});
const [altrui] = await leggi<{ email_notifiche: boolean }>(
`preferenze_utente?auth_user_id=eq.${idAdmin}`,
);
assert.equal(altrui?.email_notifiche, false, "l'update altrui non ha toccato nulla");
});
// --- 4. esito_notifica_email --------------------------------------------------------
await prova(
"'inviata': stato, orario, e la riga entra nel conteggio delle 24 ore",
async () => {
const prima = (await rpc<number>("email_inviate_ultime_24h", {})).dati;
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "inviata");
const riga = await coda(id);
assert.equal(riga?.stato, "inviata");
assert.ok(riga?.inviata_il);
assert.equal((await rpc<number>("email_inviate_ultime_24h", {})).dati, prima + 1);
},
);
await prova(
"'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata",
async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
const tra1h = new Date(Date.now() + 60 * 60_000).toISOString();
await esito(id, "riprova", "ETIMEDOUT", tra1h);
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 1);
assert.equal(riga?.errore, "ETIMEDOUT");
assert.equal((await prendi([id])).length, 0, "non ancora scaduto il ritardo");
const passato = new Date(Date.now() - 1000).toISOString();
await scrivi(`notifiche_email_coda?notifica_id=eq.${id}`, SERVIZIO, "PATCH", {
prossimo_tentativo: passato,
});
const [p] = await prendi([id]);
assert.equal(p?.tentativi_fatti, 1, "il worker vede quanti tentativi sono già stati fatti");
},
);
await prova("'differita': torna in coda senza consumare un tentativo", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "differita", "550", new Date(Date.now() + 60 * 60_000).toISOString());
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
});
await prova("'fallita' è definitiva e non viene più presa", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await prendi([id]);
await esito(id, "fallita", "550");
assert.equal((await coda(id))?.stato, "fallita");
assert.equal((await prendi([id])).length, 0);
});
await prova("un esito su una riga non 'in_invio' non cambia nulla", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
await esito(id, "inviata"); // ancora in_coda: nessuno l'ha presa
assert.equal((await coda(id))?.stato, "in_coda");
});
await prova("un esito sconosciuto è rifiutato", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const r = await rpc("esito_notifica_email", { p_id: id, p_esito: "boh" });
assert.ok(!r.ok);
});
// --- 5. Permessi --------------------------------------------------------------------
await prova("un giocatore non vede né scrive la coda", async () => {
const id = await creaNotifica(SLOT_COLLEGATO);
creati.push(id);
const lettura = await rest("notifiche_email_coda?select=*", tokenGiocatore);
const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : [];
assert.equal(righe.length, 0);
const scrittura = await rest(`notifiche_email_coda?notifica_id=eq.${id}`, tokenGiocatore, {
method: "PATCH",
body: JSON.stringify({ stato: "in_coda", tentativi: 0 }),
});
assert.ok(!scrittura.ok || (await scrittura.text()) === "");
const inserimento = await rest("notifiche_email_coda", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ notifica_id: id }),
});
assert.ok(!inserimento.ok);
assert.equal((await coda(id))?.stato, "in_coda");
});
await prova("un admin non può usare le funzioni del worker: solo la service role", async () => {
for (const [nome, corpo] of [
["prendi_notifiche_email", { p_max: 1 }],
["esito_notifica_email", { p_id: creati[0], p_esito: "fallita" }],
["email_inviate_ultime_24h", {}],
] as const) {
for (const token of [tokenAdmin, tokenGiocatore]) {
const r = await rpc(nome, { ...corpo }, token);
assert.ok(!r.ok, `${nome} non deve essere chiamabile da un utente`);
}
}
});
} finally {
await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(
`notifiche_utente?evento_id=in.(${[EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON].join(",")})`,
SERVIZIO,
{ method: "DELETE" },
);
for (const id of [EVENTO_TIPI, EVENTO_UPSERT, EVENTO_PRESA, EVENTO_CRON]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
if (tokenAdmin) {
for (const [id, valori] of originali) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify(valori),
});
}
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("notifiche-email");
}
}
+406
View File
@@ -0,0 +1,406 @@
/** Logica pura del worker email (M22): `bun test/unit/mailer-core.test.ts`. Nessuna rete né SMTP. */
import assert from "node:assert/strict";
import {
MAX_TENTATIVI,
classificaErroreSmtp,
componiMail,
descriviErrore,
elaboraCiclo,
escapeHtml,
invioConsentiti,
leggiConfig,
linkNotifica,
messageId,
pulisciOggetto,
ritardoRiprova,
staSano,
type Dipendenze,
type EsitoInvio,
type MailComposta,
type NotificaEmail,
} from "../../mailer/mailer-core.ts";
import { prova, riepilogo } from "../helpers/prova";
const ENV_BASE = {
SUPABASE_URL: "https://x.supabase.co/",
SUPABASE_SERVICE_ROLE_KEY: "chiave",
SMTP_USER: "crapp@gmail.com",
SMTP_PASS: "password-per-app",
APP_URL: "https://crapp.example/",
};
// --- leggiConfig --------------------------------------------------------------
await prova("configurazione completa: default, slash finali tolti, From = SMTP_USER", () => {
const r = leggiConfig(ENV_BASE);
assert.ok("config" in r);
const c = r.config;
assert.equal(c.supabaseUrl, "https://x.supabase.co");
assert.equal(c.appUrl, "https://crapp.example");
assert.equal(c.from, "crapp@gmail.com");
assert.equal(c.fromName, "CrAPP");
assert.deepEqual(c.smtp, {
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: { user: "crapp@gmail.com", pass: "password-per-app" },
});
assert.equal(c.pollSecondi, 30);
assert.equal(c.limiteGiorno, 400);
assert.equal(c.lotto, 10);
});
await prova("elenca insieme tutte le variabili mancanti", () => {
const r = leggiConfig({});
assert.ok("errori" in r);
assert.deepEqual(r.errori, [
"SUPABASE_URL mancante",
"SUPABASE_SERVICE_ROLE_KEY mancante",
"APP_URL mancante",
"MAIL_FROM mancante (o SMTP_USER)",
]);
});
await prova("SMTP_USER e SMTP_PASS vanno insieme", () => {
const r = leggiConfig({ ...ENV_BASE, SMTP_PASS: "" });
assert.ok("errori" in r);
assert.ok(r.errori.includes("SMTP_USER e SMTP_PASS vanno impostati insieme"));
});
await prova("senza credenziali SMTP (server di prova) serve MAIL_FROM e non c'è auth", () => {
const { SMTP_USER: _u, SMTP_PASS: _p, ...senza } = ENV_BASE;
assert.ok("errori" in leggiConfig(senza));
const r = leggiConfig({ ...senza, MAIL_FROM: "prova@crapp.local", SMTP_PORT: "1025" });
assert.ok("config" in r);
assert.equal(r.config.smtp.auth, null);
assert.equal(r.config.smtp.port, 1025);
});
await prova("un numero non valido è un errore, non un default silenzioso", () => {
for (const valore of ["0", "-5", "abc", "1.5"]) {
const r = leggiConfig({ ...ENV_BASE, POLL_SECONDI: valore });
assert.ok("errori" in r, valore);
assert.deepEqual(r.errori, ["POLL_SECONDI deve essere un intero positivo"]);
}
});
// --- classificaErroreSmtp -----------------------------------------------------
await prova("il tetto giornaliero di Gmail non è un errore permanente", () => {
const errore = {
responseCode: 550,
response: "550-5.4.5 Daily user sending quota exceeded.",
};
assert.equal(classificaErroreSmtp(errore), "limite");
});
await prova("credenziali rifiutate: configurazione, non colpa della mail", () => {
assert.equal(classificaErroreSmtp({ code: "EAUTH", responseCode: 535 }), "configurazione");
assert.equal(classificaErroreSmtp({ responseCode: 534 }), "configurazione");
});
await prova("5xx sul destinatario e indirizzo non valido sono permanenti", () => {
assert.equal(
classificaErroreSmtp({ responseCode: 550, response: "550 no such user" }),
"permanente",
);
assert.equal(classificaErroreSmtp({ code: "EENVELOPE" }), "permanente");
});
await prova("4xx e errori di rete sono temporanei", () => {
assert.equal(classificaErroreSmtp({ responseCode: 421 }), "temporaneo");
assert.equal(classificaErroreSmtp({ code: "ETIMEDOUT" }), "temporaneo");
assert.equal(classificaErroreSmtp({ code: "ECONNECTION" }), "temporaneo");
assert.equal(classificaErroreSmtp(new Error("boh")), "temporaneo");
assert.equal(classificaErroreSmtp(null), "temporaneo");
});
await prova("descriviErrore riporta solo codici, mai il testo della risposta", () => {
const errore = { code: "EMESSAGE", responseCode: 550, response: "550 nome@gmail.com non esiste" };
assert.equal(descriviErrore(errore), "EMESSAGE 550");
assert.equal(descriviErrore(new Error("mario@gmail.com")), "errore sconosciuto");
});
// --- backoff e tetto ----------------------------------------------------------
await prova("backoff 1 · 5 · 30 · 120 minuti, poi nessun altro tentativo", () => {
assert.deepEqual([0, 1, 2, 3].map(ritardoRiprova), [1, 5, 30, 120]);
assert.equal(ritardoRiprova(4), null);
assert.equal(MAX_TENTATIVI, 5);
});
await prova("invioConsentiti non supera né il lotto né il tetto delle 24 ore", () => {
assert.equal(invioConsentiti(400, 0, 10), 10);
assert.equal(invioConsentiti(400, 395, 10), 5);
assert.equal(invioConsentiti(400, 400, 10), 0);
assert.equal(invioConsentiti(400, 450, 10), 0);
});
await prova("staSano: sano solo con un ciclo riuscito abbastanza recente", () => {
assert.equal(staSano(null, 1_000_000, 300_000), false);
assert.equal(staSano(900_000, 1_000_000, 300_000), true);
assert.equal(staSano(600_000, 1_000_000, 300_000), false);
});
// --- composizione della mail --------------------------------------------------
const notifica = (extra: Partial<NotificaEmail> = {}): NotificaEmail => ({
id_notifica: "n-1",
destinatario: "mario@gmail.com",
oggetto: "Promemoria: Allenamento",
testo: "30/09/2026 alle 20:30",
id_evento: "ev-1",
tipo_notifica: "evento_promemoria_24h",
tentativi_fatti: 0,
...extra,
});
const OPZ = { appUrl: "https://crapp.example", dominioMessageId: "gmail.com" };
await prova("link: scheda evento se c'è, altrimenti la home; id codificato", () => {
assert.equal(linkNotifica("https://a.it/", "ev-1"), "https://a.it/evento/ev-1");
assert.equal(linkNotifica("https://a.it", null), "https://a.it");
assert.equal(linkNotifica("https://a.it", "a b/c"), "https://a.it/evento/a%20b%2Fc");
});
await prova("Message-ID deterministico per notifica", () => {
assert.equal(messageId("abc", "gmail.com"), "<abc@gmail.com>");
assert.equal(componiMail(notifica(), OPZ).messageId, componiMail(notifica(), OPZ).messageId);
});
await prova("oggetto su una riga sola, con un tetto e un ripiego", () => {
assert.equal(pulisciOggetto("Ciao\r\nBcc: x@y.it"), "Ciao Bcc: x@y.it");
assert.equal(pulisciOggetto(" "), "Notifica CrAPP");
assert.equal(pulisciOggetto("a".repeat(500)).length, 200);
});
await prova("la mail ha testo, HTML, link all'evento e come disattivarle", () => {
const m = componiMail(notifica(), OPZ);
assert.equal(m.to, "mario@gmail.com");
assert.equal(m.subject, "Promemoria: Allenamento");
assert.match(m.text, /30\/09\/2026 alle 20:30/);
assert.match(m.text, /https:\/\/crapp\.example\/evento\/ev-1/);
assert.match(m.text, /Profilo → Opzioni → Email/);
assert.match(m.html, /href="https:\/\/crapp\.example\/evento\/ev-1"/);
assert.match(m.html, /Apri CrAPP/);
});
await prova("il testo dell'admin non inietta HTML e conserva gli a-capo", () => {
const m = componiMail(
notifica({ testo: `<img src=x onerror=alert(1)>\nriga "2"`, id_evento: null }),
OPZ,
);
assert.ok(!m.html.includes("<img"));
assert.match(m.html, /&lt;img src=x onerror=alert\(1\)&gt;<br>riga &quot;2&quot;/);
assert.match(m.html, /href="https:\/\/crapp\.example"/);
assert.equal(escapeHtml(`<&>"'`), "&lt;&amp;&gt;&quot;&#39;");
});
await prova("titolo con HTML: l'intestazione della mail è sempre esclusa dal markup", () => {
const m = componiMail(notifica({ oggetto: "<b>Ciao</b>" }), OPZ);
assert.ok(!m.html.includes("<b>"));
});
await prova("corpo vuoto: niente paragrafo vuoto", () => {
const m = componiMail(notifica({ testo: "" }), OPZ);
assert.ok(!m.html.includes('<p style="margin:0 0 16px"></p>'));
assert.ok(!m.text.startsWith("\n"));
});
// --- elaboraCiclo -------------------------------------------------------------
type Chiamata = { id: string; esito: EsitoInvio; errore?: string; prossimo?: Date };
function ambiente(opzioni: {
lotto?: NotificaEmail[];
inviate24h?: number;
invia?: (mail: MailComposta) => Promise<void>;
segna?: Dipendenze["segna"];
}) {
const chiamate: Chiamata[] = [];
const inviate: MailComposta[] = [];
const log: string[] = [];
let richiesto = -1;
const ADESSO = new Date("2026-09-29T10:00:00Z");
const dip: Dipendenze = {
prendi: async (max) => {
richiesto = max;
return opzioni.lotto ?? [];
},
segna:
opzioni.segna ??
(async (id, esito, errore, prossimo) => {
chiamate.push({
id,
esito,
...(errore !== undefined ? { errore } : {}),
...(prossimo ? { prossimo } : {}),
});
}),
invia:
opzioni.invia ??
(async (mail) => {
inviate.push(mail);
}),
inviateUltime24h: async () => opzioni.inviate24h ?? 0,
adesso: () => ADESSO,
log: (m) => log.push(m),
};
return { dip, chiamate, inviate, log, ADESSO, richiesto: () => richiesto };
}
const OPZ_CICLO = { limiteGiorno: 400, lotto: 10, ...OPZ };
await prova("ciclo: invia ogni mail e segna 'inviata'", async () => {
const a = ambiente({ lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })] });
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.inviate, 2);
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[
["a", "inviata"],
["b", "inviata"],
],
);
assert.equal(a.inviate.length, 2);
});
await prova("ciclo: chiede al massimo quanto consente il tetto giornaliero", async () => {
const a = ambiente({ inviate24h: 396 });
await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(a.richiesto(), 4);
});
await prova("ciclo: tetto raggiunto, non prende nulla dalla coda", async () => {
const a = ambiente({ inviate24h: 400, lotto: [notifica()] });
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "limite");
assert.equal(a.richiesto(), -1);
assert.equal(a.chiamate.length, 0);
});
await prova("ciclo: errore temporaneo, riprova con backoff sul numero di tentativi", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a", tentativi_fatti: 2 })],
invia: async () => {
throw { code: "ETIMEDOUT" };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.riprovate, 1);
const [c] = a.chiamate;
assert.equal(c?.esito, "riprova");
assert.equal(c?.prossimo?.getTime(), a.ADESSO.getTime() + 30 * 60_000);
});
await prova("ciclo: all'ultimo tentativo l'errore temporaneo diventa 'fallita'", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a", tentativi_fatti: 4 })],
invia: async () => {
throw { responseCode: 421 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fallite, 1);
assert.equal(a.chiamate[0]?.esito, "fallita");
});
await prova("ciclo: errore permanente, subito 'fallita' senza riprovare", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" })],
invia: async () => {
throw { responseCode: 550, response: "550 no such user" };
},
});
await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(a.chiamate[0]?.esito, "fallita");
assert.equal(a.chiamate[0]?.errore, "550");
});
await prova("ciclo: una mail fallita non ferma le successive", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async (mail) => {
if (mail.messageId.startsWith("<a@")) throw { responseCode: 550 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fallite, 1);
assert.equal(r.inviate, 1);
assert.equal(r.fermato, null);
});
await prova(
"ciclo: tetto Gmail a metà lotto, rimanda questa e le successive e si ferma",
async () => {
let n = 0;
const a = ambiente({
lotto: [
notifica({ id_notifica: "a" }),
notifica({ id_notifica: "b" }),
notifica({ id_notifica: "c" }),
],
invia: async () => {
n += 1;
if (n === 2)
throw { responseCode: 550, response: "550-5.4.5 Daily user sending quota exceeded" };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "limite");
assert.equal(r.inviate, 1);
assert.equal(r.differite, 2);
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[
["a", "inviata"],
["b", "differita"],
["c", "differita"],
],
);
assert.equal(a.chiamate[1]?.prossimo?.getTime(), a.ADESSO.getTime() + 60 * 60_000);
},
);
await prova(
"ciclo: credenziali SMTP rifiutate, tutto differito senza consumare tentativi",
async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async () => {
throw { code: "EAUTH", responseCode: 535 };
},
});
const r = await elaboraCiclo(a.dip, OPZ_CICLO);
assert.equal(r.fermato, "configurazione");
assert.deepEqual(
a.chiamate.map((c) => c.esito),
["differita", "differita"],
);
assert.equal(r.fallite, 0);
},
);
await prova(
"ciclo: se la registrazione dell'esito fallisce non si riprova la mail già inviata",
async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" })],
segna: async () => {
throw new Error("rete giù");
},
});
await assert.rejects(elaboraCiclo(a.dip, OPZ_CICLO), /rete giù/);
assert.equal(a.inviate.length, 1);
},
);
await prova("ciclo: i log non contengono indirizzi né testi", async () => {
const a = ambiente({
lotto: [notifica({ id_notifica: "a" }), notifica({ id_notifica: "b" })],
invia: async (mail) => {
if (mail.messageId.startsWith("<b@"))
throw { responseCode: 550, response: "550 mario@gmail.com" };
},
});
await elaboraCiclo(a.dip, OPZ_CICLO);
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("mario@gmail.com"));
assert.ok(!tutto.includes("30/09/2026"));
assert.ok(!tutto.includes("Allenamento"));
});
riepilogo("mailer-core");
+16
View File
@@ -0,0 +1,16 @@
/** Preferenze dell'account (M22): `bun test/unit/preferenze-utente.test.ts`. */
import assert from "node:assert/strict";
import { emailAttive } from "@/lib/preferenze-utente";
import { prova, riepilogo } from "../helpers/prova";
await prova("senza riga le email sono attive (default DD-036)", () => {
assert.equal(emailAttive(null), true);
assert.equal(emailAttive(undefined), true);
});
await prova("con la riga vale il valore scelto dal giocatore", () => {
assert.equal(emailAttive({ email_notifiche: true }), true);
assert.equal(emailAttive({ email_notifiche: false }), false);
});
riepilogo("preferenze-utente");