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:
1 parent
58eba642f7
commit
e9013b703b
27 files changed
+2076
-92
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user