Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038)

m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore
già generati, così eliminare la notifica non li fa rigenerare dal cron né
riaccodare la mail a ogni giro.

m24: la push dei promemoria, che nascono nel database, la manda il worker
mailer/ con lo stesso modulo dell'app (coda notifiche_push_coda, esiti, backoff,
iscrizioni scadute eliminate, push scartate oltre 3 ore). Il sondaggio
pre-partita scrive anche notifiche_utente (tipo sondaggio_cacche): compare
in-app e arriva per email. L'immagine del worker si costruisce dalla radice
del repository per includere src/lib/webpush.server.ts.

Test: 9 di integrazione per m23, 17 per la coda push, 6 per la route del
sondaggio, nuovi casi unitari sul worker; prova end-to-end con un servizio
push finto. Documentazione e catalogo delle notifiche aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-09-29 14:19:54 +02:00
1 parent 58eba642f7
commit e9013b703b
27 files changed
+2076 -92

No files matched your search

+20 -6
View File
@@ -24,12 +24,26 @@ 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, 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 | — |
| 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 in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | 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), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), 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 | — |
## Server di sviluppo con Node vecchio
I test che avviano il server (`api`, `notifiche-attive-route`, `notifiche-utente`, `permessi-route`,
`sondaggio-notifiche`) chiamano `npm run dev`, che richiede un Node recente: con Node 18 Vite non parte.
Si può avviare il server con bun, puntato allo stack locale, e dire ai test di usarlo:
```bash
SUPABASE_URL=… SUPABASE_PUBLISHABLE_KEY=… SUPABASE_SERVICE_ROLE_KEY=… bun --bun x vite dev --port 8080
BASE_URL=http://localhost:8080 npm run test:integration
```
(`npx supabase status -o env` dà i valori dello stack locale.) Il 29/09/2026 l'intera suite di
integrazione, 30 file, passa così.
## Database locale in Docker
+225
View File
@@ -0,0 +1,225 @@
/**
* 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<T>(percorso: string): Promise<T[]> {
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");
}
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Push dei promemoria e sondaggio in-app (M24, DD-038): `bun test/integration/push-promemoria.test.ts`.
*
* Lato database: il trigger che accoda solo i promemoria a 24 e 3 ore, le funzioni RPC del worker
* (`prendi_push_promemoria`, `esito_push_promemoria`), la RLS che tiene la coda lontana dai
* client, e il nuovo tipo `sondaggio_cacche`. Il worker vero e la logica degli esiti sono coperti
* da `unit/mailer-core.test.ts`; qui non parte nessuna push.
*
* Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g4` della rosa seed e
* iscrizioni push con endpoint `https://push.test-push-promemoria.example/…`, tolte 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("push promemoria", "stack locale non attivo (npx supabase start)");
riepilogo("push-promemoria");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`push promemoria su ${URL_BASE}`);
const PREFISSO = "test-push-promemoria";
const G4 = "g4";
const G5 = "g5";
const ENDPOINT = (n: number) => `https://push.${PREFISSO}.example/${n}`;
const EVENTI = ["a", "b", "c", "d"].map((x) => `${PREFISSO}-${x}`);
let contatore = 0;
const PASSWORD = "prova-push-promemoria-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, 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 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, dati: (testo ? JSON.parse(testo) : null) as T };
}
async function creaUtente(email: string): Promise<{ id: string; token: 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)}`);
const login = 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 t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
type Coda = {
notifica_id: string;
stato: string;
tentativi: number;
prossimo_tentativo: string;
errore: string | null;
inviata_il: string | null;
};
type Preso = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: Array<{ endpoint: string; p256dh: string; auth: string }>;
};
const evento = async (id: string, ore = 2) => {
const quando = new Date(Date.now() + ore * 3_600_000);
await scrivi("eventi_app", "POST", {
id,
tipo: "allenamento",
titolo: `${PREFISSO} ${id}`,
data: new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando),
ora: new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(quando),
convocati: [G4],
});
};
const creaNotifica = async (
giocatoreId: string,
tipo: string,
eventoId: string | null = null,
): Promise<string> => {
contatore += 1;
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
giocatore_id: giocatoreId,
tipo,
titolo: `${PREFISSO} titolo ${contatore}`,
corpo: `${PREFISSO} corpo`,
...(eventoId ? { evento_id: eventoId } : {}),
}),
});
if (!res.ok) throw new Error(`insert notifica: ${res.status} ${await res.text()}`);
return ((await res.json()) as { id: string }[])[0]!.id;
};
/** Evento nuovo e un promemoria per g4: (giocatore, evento, tipo) è UNIQUE, ogni prova ha il suo. */
const eventiCreati: string[] = [];
const fresco = async (tipo = "evento_promemoria_24h"): Promise<string> => {
contatore += 1;
const id = `${PREFISSO}-x${contatore}`;
await evento(id, 40);
eventiCreati.push(id);
return creaNotifica(G4, tipo, id);
};
const coda = async (id: string): Promise<Coda | undefined> =>
(await leggi<Coda>(`notifiche_push_coda?notifica_id=eq.${id}&select=*`))[0];
const codaEmail = async (id: string) =>
(await leggi<{ stato: string }>(`notifiche_email_coda?notifica_id=eq.${id}&select=stato`))[0];
const prendi = async (mie: string[], max = 100): Promise<Preso[]> => {
const r = await rpc<Preso[]>("prendi_push_promemoria", { p_max: max });
assert.ok(r.ok, "prendi_push_promemoria");
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_push_promemoria", {
p_id: id,
p_esito: tipo,
p_errore: errore,
p_prossimo: prossimo,
});
assert.ok(r.ok, `esito_push_promemoria ${tipo}`);
};
const iscrivi = (giocatoreId: string, n: number) =>
scrivi("push_subscriptions", "POST", {
giocatore_id: giocatoreId,
endpoint: ENDPOINT(n),
p256dh: `k${n}`,
auth: `a${n}`,
});
const idUtenti: string[] = [];
try {
const utente = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(utente.id);
for (const id of EVENTI) await evento(id);
// --- 1. Trigger ---------------------------------------------------------------------
await prova("solo i promemoria a 24 e 3 ore finiscono nella coda push", async () => {
const promemoria = [
await creaNotifica(G4, "evento_promemoria_24h", EVENTI[0]),
await creaNotifica(G4, "evento_promemoria_3h", EVENTI[0]),
];
const altre = [
await creaNotifica(G4, "admin"),
await creaNotifica(G4, "turno_palloni", EVENTI[0]),
await creaNotifica(G4, "sollecita_presenze", EVENTI[0]),
await creaNotifica(G4, "sondaggio_cacche", EVENTI[0]),
];
for (const id of promemoria) {
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 0);
}
for (const id of altre) {
assert.equal(await coda(id), undefined, "la push di queste parte già dall'app");
}
});
await prova(
"il sondaggio è un tipo valido, e la sua mail parte dal trigger di M22",
async () => {
const id = await creaNotifica(G4, "sondaggio_cacche", EVENTI[1]);
assert.equal((await codaEmail(id))?.stato, "in_coda");
assert.equal(await coda(id), undefined);
},
);
await prova("un tipo sconosciuto è ancora rifiutato dal vincolo", async () => {
const res = await rest("notifiche_utente", SERVIZIO, {
method: "POST",
body: JSON.stringify({ giocatore_id: G4, tipo: "boh", titolo: "x", corpo: "y" }),
});
assert.ok(!res.ok);
});
// --- 2. prendi_push_promemoria ------------------------------------------------------
await prova(
"prendi restituisce testo e iscrizioni del giocatore e marca la riga in_invio",
async () => {
await iscrivi(G4, 1);
await iscrivi(G4, 2);
const id = await fresco("evento_promemoria_3h");
const [p] = await prendi([id]);
assert.ok(p, "la riga è stata presa");
assert.match(p.oggetto, new RegExp(PREFISSO));
assert.equal(p.tentativi_fatti, 0);
assert.deepEqual(p.iscrizioni.map((i) => i.endpoint).sort(), [ENDPOINT(1), ENDPOINT(2)]);
assert.equal(p.iscrizioni[0]?.p256dh.startsWith("k"), true);
assert.equal((await coda(id))?.stato, "in_invio");
},
);
await prova("una seconda chiamata non restituisce di nuovo la stessa riga", async () => {
const id = await fresco();
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: 5 }, () => fresco()));
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");
const resto = (await prendi(ids)).map((p) => p.id_notifica);
assert.deepEqual([...tutti, ...resto].sort(), [...ids].sort());
});
await prova("giocatore senza dispositivi: 'saltata', mai presa", async () => {
const id = await creaNotifica(G5, "evento_promemoria_24h", EVENTI[0]);
assert.equal((await prendi([id])).length, 0);
const riga = await coda(id);
assert.equal(riga?.stato, "saltata");
assert.equal(riga?.errore, null);
});
await prova("promemoria creato da più di 3 ore: 'saltata', la push non serve più", async () => {
const id = await fresco("evento_promemoria_3h");
const vecchia = new Date(Date.now() - 4 * 3_600_000).toISOString();
await scrivi(`notifiche_utente?id=eq.${id}`, "PATCH", { creato_il: vecchia });
assert.equal((await prendi([id])).length, 0);
assert.equal((await coda(id))?.stato, "saltata");
});
await prova(
"la riga rimasta in invio per un worker morto viene ripresa dopo 10 minuti",
async () => {
const id = await fresco();
assert.equal((await prendi([id])).length, 1);
assert.equal((await prendi([id])).length, 0, "ancora in invio");
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
aggiornata_il: new Date(Date.now() - 11 * 60_000).toISOString(),
});
assert.equal((await prendi([id])).length, 1, "recuperata");
},
);
// --- 3. esito_push_promemoria -------------------------------------------------------
const nuovaInvio = async () => {
const id = await fresco();
await prendi([id]);
return id;
};
await prova("'inviata': stato e orario, errore azzerato", async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
const riga = await coda(id);
assert.equal(riga?.stato, "inviata");
assert.ok(riga?.inviata_il);
assert.equal(riga?.errore, null);
});
await prova(
"'riprova': conta un tentativo e non viene ripresa prima dell'ora fissata",
async () => {
const id = await nuovaInvio();
await esito(id, "riprova", "503", new Date(Date.now() + 3_600_000).toISOString());
const riga = await coda(id);
assert.equal(riga?.stato, "in_coda");
assert.equal(riga?.tentativi, 1);
assert.equal(riga?.errore, "503");
assert.equal((await prendi([id])).length, 0);
await scrivi(`notifiche_push_coda?notifica_id=eq.${id}`, "PATCH", {
prossimo_tentativo: new Date(Date.now() - 1000).toISOString(),
});
const [p] = await prendi([id]);
assert.equal(p?.tentativi_fatti, 1);
},
);
await prova("'fallita' e 'saltata' sono definitive", async () => {
const a = await nuovaInvio();
await esito(a, "fallita", "403");
assert.equal((await coda(a))?.stato, "fallita");
assert.equal((await coda(a))?.tentativi, 1);
const b = await nuovaInvio();
await esito(b, "saltata", "410");
assert.equal((await coda(b))?.stato, "saltata");
assert.equal((await prendi([a, b])).length, 0);
});
await prova(
"un esito su una riga non 'in_invio' non cambia nulla, uno sconosciuto è rifiutato",
async () => {
const id = await nuovaInvio();
await esito(id, "inviata");
await esito(id, "fallita", "x");
assert.equal((await coda(id))?.stato, "inviata", "l'esito già deciso non si sovrascrive");
const r = await rpc("esito_push_promemoria", { p_id: id, p_esito: "boh" });
assert.ok(!r.ok);
},
);
await prova("cancellare la notifica toglie anche la riga della coda push", async () => {
const id = await fresco("evento_promemoria_3h");
assert.ok(await coda(id));
await scrivi(`notifiche_utente?id=eq.${id}`, "DELETE");
assert.equal(await coda(id), undefined);
});
await prova(
"il job dei promemoria accoda la push una volta sola, anche se cancelli la notifica",
async () => {
await scrivi(`eventi_app?id=eq.${EVENTI[2]}`, "PATCH", { convocati: [G4] });
const args = { p_tipo: "evento_promemoria_24h", p_finestra: "24 hours" };
await rpc("genera_promemoria_eventi", args);
const [n] = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.ok(n, "il job ha creato il promemoria");
assert.equal((await coda(n.id))?.stato, "in_coda");
await scrivi(`notifiche_utente?id=eq.${n.id}`, "DELETE");
assert.equal(await coda(n.id), undefined, "la coda sparisce con la notifica");
await rpc("genera_promemoria_eventi", args);
const dopo = await leggi<{ id: string }>(
`notifiche_utente?evento_id=eq.${EVENTI[2]}&tipo=eq.evento_promemoria_24h&select=id`,
);
assert.equal(
dopo.length,
0,
"M23: il promemoria eliminato non torna, quindi nessuna nuova push",
);
},
);
// --- 4. Permessi --------------------------------------------------------------------
await prova("un utente autenticato non vede né scrive la coda push", async () => {
const id = await nuovaInvio();
const lettura = await rest("notifiche_push_coda?select=*", utente.token);
const righe = lettura.ok ? ((await lettura.json()) as unknown[]) : [];
assert.equal(righe.length, 0);
const scrittura = await rest(`notifiche_push_coda?notifica_id=eq.${id}`, utente.token, {
method: "PATCH",
body: JSON.stringify({ stato: "in_coda" }),
});
assert.ok(!scrittura.ok || (await scrittura.text()) === "");
assert.equal((await coda(id))?.stato, "in_invio");
const anon = await rest("notifiche_push_coda?select=*", ANON);
assert.ok(!anon.ok, "anon non può leggere la coda");
});
await prova("le funzioni push del worker sono chiamabili solo dalla service role", async () => {
const id = await nuovaInvio();
for (const token of [utente.token, ANON]) {
const a = await rpc("prendi_push_promemoria", { p_max: 1 }, token);
assert.ok(!a.ok, "prendi_push_promemoria");
const b = await rpc("esito_push_promemoria", { p_id: id, p_esito: "fallita" }, token);
assert.ok(!b.ok, "esito_push_promemoria");
}
assert.equal((await coda(id))?.stato, "in_invio");
});
} finally {
await rest(
`push_subscriptions?endpoint=like.${encodeURIComponent(`https://push.${PREFISSO}.example/*`)}`,
SERVIZIO,
{
method: "DELETE",
},
);
await rest(`notifiche_utente?titolo=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
for (const id of [...EVENTI, ...eventiCreati]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
for (const id of idUtenti) {
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("push-promemoria");
}
}
@@ -0,0 +1,250 @@
/**
* Sondaggio pre-partita in-app ed email (M24, DD-038): `bun test/integration/sondaggio-notifiche.test.ts`.
*
* La route `apri-sondaggio` mandava solo la push. Ora scrive anche `notifiche_utente` (tipo
* `sondaggio_cacche`) per i giocatori attivi, allenatori esclusi: compare nel centro notifiche e
* la mail parte dal trigger di M22. Qui si verifica quella scrittura chiamando la route vera.
*
* Gira solo sullo stack locale (`npx supabase start`) e avvia il server di sviluppo puntato al
* database locale (o usa quello indicato da `BASE_URL`). Usa gli slot `g5` (reso non attivo) e
* `g6` (reso allenatore) della rosa seed, ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("sondaggio in-app", "stack locale non attivo (npx supabase start)");
riepilogo("sondaggio-notifiche");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`sondaggio in-app su ${URL_BASE}`);
// Il server di sviluppo eredita queste: senza, le route punterebbero al progetto cloud.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PREFISSO = "test-sondaggio-notifiche";
const PARTITA = `${PREFISSO}-partita`;
const INATTIVO = "g5";
const ALLENATORE = "g6";
const PASSWORD = "prova-sondaggio-notifiche-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): Promise<T[]> {
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, token: string, metodo: string, corpo?: unknown) {
const res = await rest(percorso, token, {
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 creaUtente(email: string): Promise<{ id: string; token: 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)}`);
const login = 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 t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
type Notifica = {
id: string;
giocatore_id: string;
letta: boolean;
titolo: string;
creato_il: string;
};
const notifiche = () =>
leggi<Notifica>(
`notifiche_utente?evento_id=eq.${PARTITA}&tipo=eq.sondaggio_cacche&select=id,giocatore_id,letta,titolo,creato_il&order=giocatore_id`,
);
const idUtenti: string[] = [];
let server: { baseUrl: string; stop: () => void } | null = null;
let tokenAdmin = "";
const originali = new Map<string, { attivo: boolean; tipo: string }>();
try {
const admin = await creaUtente(`${PREFISSO}-admin-${Date.now()}@example.test`);
const giocatore = await creaUtente(`${PREFISSO}-giocatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, giocatore.id);
await scrivi("user_roles", SERVIZIO, "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [INATTIVO, ALLENATORE]) {
const [riga] = await leggi<{ attivo: boolean; tipo: string }>(
`giocatori_squadra?id=eq.${id}&select=attivo,tipo`,
);
originali.set(id, riga ?? { attivo: true, tipo: "giocatore" });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, tokenAdmin, "PATCH", { attivo: false });
await scrivi(`giocatori_squadra?id=eq.${ALLENATORE}`, tokenAdmin, "PATCH", {
tipo: "allenatore",
});
await scrivi("eventi_app", SERVIZIO, "POST", {
id: PARTITA,
tipo: "partita",
titolo: `${PREFISSO} partita`,
data: "2030-01-01",
ora: "21:00",
convocati: [],
});
const attesi = (
await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&tipo=eq.giocatore&select=id")
)
.map((g) => g.id)
.sort();
assert.ok(attesi.length > 3, "la rosa seed ha più giocatori attivi");
assert.ok(!attesi.includes(INATTIVO) && !attesi.includes(ALLENATORE));
server = await avviaServer();
console.log(`route apri-sondaggio su ${server.baseUrl}`);
const chiama = (token: string) =>
fetch(`${server!.baseUrl}/api/public/apri-sondaggio`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ eventoId: PARTITA }),
});
await prova("chi non è admin non può aprire il sondaggio e non scrive nulla", async () => {
const res = await chiama(giocatore.token);
assert.ok(!res.ok, `stato ${res.status}`);
assert.equal((await notifiche()).length, 0);
});
await prova("l'admin apre il sondaggio: una notifica per ogni giocatore attivo", async () => {
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const righe = await notifiche();
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), attesi);
assert.ok(righe.every((r) => r.titolo === "💩 Sondaggio pre-partita aperto"));
assert.ok(righe.every((r) => !r.letta));
});
await prova("l'allenatore e chi non è attivo non ricevono l'avviso in-app", async () => {
const righe = await notifiche();
assert.ok(!righe.some((r) => r.giocatore_id === ALLENATORE));
assert.ok(!righe.some((r) => r.giocatore_id === INATTIVO));
});
await prova(
"l'avviso arriva anche a chi non ha nessun dispositivo iscritto alla push",
async () => {
const iscritti = await leggi<{ giocatore_id: string }>(
"push_subscriptions?select=giocatore_id",
);
const conPush = new Set(iscritti.map((i) => i.giocatore_id));
const senza = attesi.filter((id) => !conPush.has(id));
assert.ok(senza.length > 0, "nel database di prova nessuno ha la push");
const righe = await notifiche();
for (const id of senza)
assert.ok(
righe.some((r) => r.giocatore_id === id),
id,
);
},
);
await prova(
"ogni notifica del sondaggio ha la sua mail in coda e nessuna riga push",
async () => {
const righe = await notifiche();
const ids = righe.map((r) => r.id).join(",");
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(email.length, righe.length, "una mail per notifica");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=in.(${ids})&select=notifica_id`,
);
assert.equal(push.length, 0, "la push del sondaggio parte già dalla route");
},
);
await prova(
"ripremere il pulsante aggiorna le notifiche, riporta a non letta e rimanda la mail",
async () => {
const prima = await notifiche();
const [scelta] = prima;
assert.ok(scelta);
await scrivi(`notifiche_utente?id=eq.${scelta.id}`, SERVIZIO, "PATCH", { letta: true });
await scrivi(`notifiche_email_coda?notifica_id=eq.${scelta.id}`, SERVIZIO, "PATCH", {
stato: "inviata",
});
const res = await chiama(tokenAdmin);
assert.equal(res.status, 200);
const dopo = await notifiche();
assert.equal(
dopo.length,
prima.length,
"nessun doppione: upsert su giocatore, evento e tipo",
);
const aggiornata = dopo.find((r) => r.id === scelta.id);
assert.equal(aggiornata?.letta, false);
assert.ok(aggiornata!.creato_il > scelta.creato_il, "creato_il rinnovato");
const [coda] = await leggi<{ stato: string }>(
`notifiche_email_coda?notifica_id=eq.${scelta.id}&select=stato`,
);
assert.equal(coda?.stato, "in_coda", "la mail riparte");
},
);
} finally {
server?.stop();
await rest(`notifiche_utente?evento_id=eq.${PARTITA}`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=eq.${PARTITA}`, 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 fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("sondaggio-notifiche");
}
}
+22
View File
@@ -1,6 +1,8 @@
/** Check del sondaggio cacche: `bun test/unit/cacche.test.ts`. */
import assert from "node:assert/strict";
import {
avvisoSondaggio,
destinatariSondaggio,
mediaPartita,
mediaStagione,
recordStagione,
@@ -118,4 +120,24 @@ assert.equal(
"partita passata: terminato",
);
// --- Avviso del sondaggio (DD-038) ---------------------------------------------
assert.deepEqual(avvisoSondaggio({ titolo: "Juve - CRAP", ora: "21:00" }), {
titolo: "💩 Sondaggio pre-partita aperto",
testo: "Juve - CRAP · ore 21:00. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.",
});
// Destinatari in-app ed email: giocatori attivi, mai gli allenatori né chi non è più attivo,
// a prescindere dai dispositivi iscritti alla push.
assert.deepEqual(
destinatariSondaggio([
{ id: "g1", attivo: true, tipo: "giocatore" },
{ id: "g2", attivo: false, tipo: "giocatore" },
{ id: "g3", attivo: true, tipo: "allenatore" },
{ id: "g4", attivo: true, tipo: "giocatore" },
]),
["g1", "g4"],
"solo i giocatori attivi",
);
assert.deepEqual(destinatariSondaggio([]), [], "rosa vuota: nessun destinatario");
console.log("cacche: ok");
+226
View File
@@ -3,9 +3,11 @@ import assert from "node:assert/strict";
import {
MAX_TENTATIVI,
classificaErroreSmtp,
classificaStatoPush,
componiMail,
descriviErrore,
elaboraCiclo,
elaboraCicloPush,
escapeHtml,
invioConsentiti,
leggiConfig,
@@ -15,9 +17,13 @@ import {
ritardoRiprova,
staSano,
type Dipendenze,
type DipendenzePush,
type EsitoInvio,
type EsitoPush,
type IscrizionePush,
type MailComposta,
type NotificaEmail,
type PushDaInviare,
} from "../../mailer/mailer-core.ts";
import { prova, riepilogo } from "../helpers/prova";
@@ -403,4 +409,224 @@ await prova("ciclo: i log non contengono indirizzi né testi", async () => {
assert.ok(!tutto.includes("Allenamento"));
});
// --- Configurazione VAPID (M24) -----------------------------------------------
await prova("senza chiavi VAPID il worker parte e manda solo le email", () => {
const r = leggiConfig(ENV_BASE);
assert.ok("config" in r);
assert.equal(r.config.vapid, null);
});
await prova("con le chiavi VAPID la push dei promemoria è attiva", () => {
const r = leggiConfig({
...ENV_BASE,
VAPID_PUBLIC_KEY: "pub",
VAPID_PRIVATE_KEY: "priv",
VAPID_SUBJECT: "mailto:a@b.it",
});
assert.ok("config" in r);
assert.deepEqual(r.config.vapid, {
publicKey: "pub",
privateKey: "priv",
subject: "mailto:a@b.it",
});
});
await prova("le chiavi VAPID vanno impostate insieme", () => {
for (const env of [{ VAPID_PUBLIC_KEY: "pub" }, { VAPID_PRIVATE_KEY: "priv" }]) {
const r = leggiConfig({ ...ENV_BASE, ...env });
assert.ok("errori" in r);
assert.deepEqual(r.errori, ["VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme"]);
}
});
// --- classificaStatoPush ------------------------------------------------------
await prova("stato HTTP del servizio push: accettata, scaduta, temporanea, permanente", () => {
for (const s of [200, 201, 202]) assert.equal(classificaStatoPush(s), "ok", String(s));
for (const s of [404, 410]) assert.equal(classificaStatoPush(s), "scaduta", String(s));
for (const s of [429, 500, 502, 503])
assert.equal(classificaStatoPush(s), "temporaneo", String(s));
for (const s of [400, 401, 403, 413])
assert.equal(classificaStatoPush(s), "permanente", String(s));
});
// --- elaboraCicloPush ---------------------------------------------------------
const disp = (n: number): IscrizionePush[] =>
Array.from({ length: n }, (_, i) => ({
endpoint: `https://push.example/${i}`,
p256dh: "k",
auth: "a",
}));
const pushDa = (extra: Partial<PushDaInviare> = {}): PushDaInviare => ({
id_notifica: "p-1",
oggetto: "Promemoria: Allenamento",
testo: "30/09/2026 alle 20:30",
tentativi_fatti: 0,
iscrizioni: disp(1),
...extra,
});
type ChiamataPush = { id: string; esito: EsitoPush; errore?: string; prossimo?: Date };
function ambientePush(opzioni: {
lotto?: PushDaInviare[];
risposta?: (iscrizione: IscrizionePush) => Promise<{ stato: number }>;
elimina?: (endpoint: string) => Promise<void>;
}) {
const chiamate: ChiamataPush[] = [];
const inviate: Array<{ endpoint: string; titolo: string; testo: string }> = [];
const eliminate: string[] = [];
const log: string[] = [];
const ADESSO = new Date("2026-09-29T10:00:00Z");
const dip: DipendenzePush = {
prendi: async () => opzioni.lotto ?? [],
segna: async (id, esito, errore, prossimo) => {
chiamate.push({
id,
esito,
...(errore !== undefined ? { errore } : {}),
...(prossimo ? { prossimo } : {}),
});
},
invia: async (iscrizione, titolo, testo) => {
inviate.push({ endpoint: iscrizione.endpoint, titolo, testo });
return opzioni.risposta ? opzioni.risposta(iscrizione) : { stato: 201 };
},
elimina:
opzioni.elimina ??
(async (endpoint) => {
eliminate.push(endpoint);
}),
adesso: () => ADESSO,
log: (m) => log.push(m),
};
return { dip, chiamate, inviate, eliminate, log, ADESSO };
}
await prova("push: manda titolo e testo a tutti i dispositivi e segna 'inviata'", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: disp(2) })] });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(a.inviate.length, 2);
assert.equal(a.inviate[0]?.titolo, "Promemoria: Allenamento");
assert.equal(a.inviate[0]?.testo, "30/09/2026 alle 20:30");
assert.deepEqual(
a.chiamate.map((c) => [c.id, c.esito]),
[["p-1", "inviata"]],
);
});
await prova("push: basta un dispositivo che accetta, quello scaduto viene eliminato", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 1);
assert.deepEqual(a.eliminate, ["https://push.example/0"]);
assert.equal(a.chiamate[0]?.esito, "inviata");
});
await prova("push: tutte le iscrizioni scadute vuol dire 'saltata', non un errore", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async () => ({ stato: 404 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.saltate, 1);
assert.equal(r.iscrizioniRimosse, 2);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: nessuna iscrizione nella riga, 'saltata' senza chiamate", async () => {
const a = ambientePush({ lotto: [pushDa({ iscrizioni: [] })] });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.inviate.length, 0);
assert.equal(a.chiamate[0]?.esito, "saltata");
});
await prova("push: errore temporaneo, riprova con backoff sul numero di tentativi", async () => {
const a = ambientePush({
lotto: [pushDa({ tentativi_fatti: 1 })],
risposta: async () => ({ stato: 503 }),
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "503");
assert.equal(a.chiamate[0]?.prossimo?.getTime(), a.ADESSO.getTime() + 5 * 60_000);
});
await prova("push: rete giù, riprova; all'ultimo tentativo 'fallita'", async () => {
const giu = async () => {
throw new Error("rete");
};
const a = ambientePush({ lotto: [pushDa({ tentativi_fatti: 0 })], risposta: giu });
await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(a.chiamate[0]?.esito, "riprova");
assert.equal(a.chiamate[0]?.errore, "rete");
const b = ambientePush({ lotto: [pushDa({ tentativi_fatti: 4 })], risposta: giu });
const r = await elaboraCicloPush(b.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(b.chiamate[0]?.esito, "fallita");
});
await prova("push: rifiuto permanente (chiavi VAPID sbagliate) è subito 'fallita'", async () => {
const a = ambientePush({ lotto: [pushDa()], risposta: async () => ({ stato: 403 }) });
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.fallite, 1);
assert.equal(a.chiamate[0]?.esito, "fallita");
assert.equal(a.chiamate[0]?.errore, "403");
});
await prova("push: una notifica che fallisce non ferma le successive", async () => {
const a = ambientePush({
lotto: [pushDa({ id_notifica: "p-1" }), pushDa({ id_notifica: "p-2", iscrizioni: disp(1) })],
risposta: async () => ({ stato: 201 }),
});
a.dip.invia = async (_i, _t, _x) => {
if (a.inviate.length === 0) {
a.inviate.push({ endpoint: "x", titolo: "", testo: "" });
throw new Error("rete");
}
return { stato: 201 };
};
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.riprovate, 1);
assert.equal(r.inviate, 1);
});
await prova("push: se l'eliminazione dell'iscrizione fallisce, il resto prosegue", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 201 }),
elimina: async () => {
throw new Error("db giù");
},
});
const r = await elaboraCicloPush(a.dip, { lotto: 10 });
assert.equal(r.inviate, 1);
assert.equal(r.iscrizioniRimosse, 0);
});
await prova("push: la registrazione dell'esito che fallisce interrompe il ciclo", async () => {
const a = ambientePush({ lotto: [pushDa()] });
a.dip.segna = async () => {
throw new Error("rete giù");
};
await assert.rejects(elaboraCicloPush(a.dip, { lotto: 10 }), /rete giù/);
});
await prova("push: i log non contengono endpoint né testi dei promemoria", async () => {
const a = ambientePush({
lotto: [pushDa({ iscrizioni: disp(2) }), pushDa({ id_notifica: "p-2" })],
risposta: async (i) => ({ stato: i.endpoint.endsWith("/0") ? 410 : 503 }),
});
await elaboraCicloPush(a.dip, { lotto: 10 });
const tutto = a.log.join("\n");
assert.ok(!tutto.includes("push.example"));
assert.ok(!tutto.includes("Allenamento"));
assert.ok(!tutto.includes("20:30"));
});
riepilogo("mailer-core");