Push reminders: personal to the notes, generic without them
The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:
- Personal: "3 cards are due in Sky deck", in the app's language, tapping
through to /review. Without notes (or with nothing due) it is the plain
nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
a path on this app, so a stolen subscription cannot make the notification
link elsewhere. The store keeps endpoint, keys, the message and createdAt
(a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
opens and whenever the tab becomes visible (throttled to twice a day), so a
count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
PUSH_REMINDER_TITLE/_BODY for a subscription that has none
(scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
text in en and cs, the generic fallback in both, the URL guard, and the
sender's preference order (81 checks).
Version 0.8.2, SW v104.
This commit is contained in:
parent
6036238bf0
commit
77819b5961
18 changed files with 498 additions and 49 deletions
|
|
@ -4,7 +4,7 @@
|
|||
* timer, and the push-reminder plumbing (hidden without VAPID, validated with
|
||||
* it).
|
||||
*/
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
BASE,
|
||||
DECK,
|
||||
REPO,
|
||||
dueSrs,
|
||||
launchBrowser,
|
||||
makeChecker,
|
||||
makePage,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
spawnApp,
|
||||
waitForHttp,
|
||||
} from "./lib.mjs";
|
||||
import { reminderPayload } from "../scripts/reminder-payload.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
|
||||
|
|
@ -498,7 +500,11 @@ const browser = await launchBrowser();
|
|||
{
|
||||
check("push: port 3209 is free", !(await portInUse(3209)));
|
||||
|
||||
mkdirSync(path.join(REPO, "tests", ".tmp", "push"), { recursive: true });
|
||||
const pushDir = path.join(REPO, "tests", ".tmp", "push");
|
||||
mkdirSync(pushDir, { recursive: true });
|
||||
/* Start from an empty store: a previous run's subscriptions (and their
|
||||
reminder texts) must never be mistaken for this run's. */
|
||||
rmSync(path.join(pushDir, "subscriptions.json"), { force: true });
|
||||
const vapid = await new Promise((resolve) => {
|
||||
const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO });
|
||||
let output = "";
|
||||
|
|
@ -550,7 +556,11 @@ const browser = await launchBrowser();
|
|||
}, subscription);
|
||||
check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body));
|
||||
const file = JSON.parse(readFileSync(path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"), "utf8"));
|
||||
check("push: stored without personal data", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys");
|
||||
check(
|
||||
"push: a subscription with no reminder text stores nothing about the notes",
|
||||
file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys",
|
||||
JSON.stringify(Object.keys(file[0] ?? {})),
|
||||
);
|
||||
|
||||
const badBody = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/push", {
|
||||
|
|
@ -562,6 +572,16 @@ const browser = await launchBrowser();
|
|||
});
|
||||
check("push: invalid subscription → 400", badBody === 400, String(badBody));
|
||||
|
||||
const badMessage = await page.evaluate(async (sub) => {
|
||||
const response = await fetch("/api/push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
|
||||
body: JSON.stringify({ ...sub, message: { title: "Hi", body: "There", url: "https://elsewhere.example/" } }),
|
||||
});
|
||||
return response.status;
|
||||
}, subscription);
|
||||
check("push: a reminder link that leaves the app → 400", badMessage === 400, String(badMessage));
|
||||
|
||||
const testSend = await page.evaluate(async (sub) => {
|
||||
const response = await fetch("/api/push/test", {
|
||||
method: "POST",
|
||||
|
|
@ -581,6 +601,119 @@ const browser = await launchBrowser();
|
|||
}, subscription);
|
||||
check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body));
|
||||
await context.close();
|
||||
|
||||
/* 5b. the reminder text is composed in the browser, from the notes ---
|
||||
The server never sees the notes, so the message each device wants
|
||||
travels with its subscription. The app's own background refresh is
|
||||
driven here with a faked browser subscription (the headless browser
|
||||
has no push service of its own) — the composer, the store and the
|
||||
read-back are all the real code. */
|
||||
const subscriptionsFile = path.join(REPO, "tests", ".tmp", "push", "subscriptions.json");
|
||||
const scenarios = [
|
||||
{
|
||||
name: "en, two cards due in Sky deck",
|
||||
locale: "en",
|
||||
decks: [DECK],
|
||||
due: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2")],
|
||||
endpoint: "https://push.example.invalid/personal-en",
|
||||
title: "Time to review",
|
||||
body: "2 cards are due in Sky deck",
|
||||
url: "/review",
|
||||
},
|
||||
{
|
||||
name: "cs, one card due",
|
||||
locale: "cs",
|
||||
decks: [DECK],
|
||||
due: [dueSrs("seed_deck", "a2")],
|
||||
endpoint: "https://push.example.invalid/personal-cs",
|
||||
title: "Čas na opakování",
|
||||
body: "1 kartička čeká v sadě Sky deck",
|
||||
url: "/review",
|
||||
},
|
||||
{
|
||||
name: "en, no notes at all",
|
||||
locale: "en",
|
||||
decks: [],
|
||||
due: [],
|
||||
endpoint: "https://push.example.invalid/generic-en",
|
||||
title: "Time to review",
|
||||
body: "Your decks are waiting — a few due cards keep the streak alive.",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
name: "cs, no notes at all",
|
||||
locale: "cs",
|
||||
decks: [],
|
||||
due: [],
|
||||
endpoint: "https://push.example.invalid/generic-cs",
|
||||
title: "Čas na opakování",
|
||||
body: "Sady na tebe čekají — pár kartiček udrží sérii.",
|
||||
url: "/",
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const personal = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const personalPage = await personal.newPage();
|
||||
await seed(personalPage, { decks: scenario.decks, locale: scenario.locale });
|
||||
await personalPage.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" });
|
||||
await personalPage.waitForTimeout(700);
|
||||
if (scenario.due.length > 0) {
|
||||
/* The home screen reads the learning store on mount, so the mirror
|
||||
is already hydrated: the records written here are picked up by a
|
||||
reload, exactly like a browser that opened the app after studying. */
|
||||
await seedLearning(personalPage, { srs: scenario.due });
|
||||
await personalPage.reload({ waitUntil: "networkidle" });
|
||||
await personalPage.waitForTimeout(700);
|
||||
}
|
||||
|
||||
await personalPage.evaluate(async (endpoint) => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
Object.defineProperty(registration, "pushManager", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getSubscription: async () => ({
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) } }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
/* The component refreshes on visibility: the first pass (no real
|
||||
subscription here) leaves it primed for exactly this event. */
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, scenario.endpoint);
|
||||
|
||||
let stored = null;
|
||||
for (let attempt = 0; attempt < 40 && !stored; attempt += 1) {
|
||||
await personalPage.waitForTimeout(150);
|
||||
const entries = JSON.parse(readFileSync(subscriptionsFile, "utf8"));
|
||||
stored = entries.find((entry) => entry.endpoint === scenario.endpoint) ?? null;
|
||||
}
|
||||
check(
|
||||
`push: ${scenario.name} — the browser-composed reminder`,
|
||||
stored?.message?.title === scenario.title &&
|
||||
stored?.message?.body === scenario.body &&
|
||||
stored?.message?.url === scenario.url,
|
||||
JSON.stringify(stored?.message ?? null),
|
||||
);
|
||||
await personal.close();
|
||||
}
|
||||
|
||||
/* The sender script's fallback: a device with no text of its own gets
|
||||
the operator's generic lines, and a link that leaves the app is never
|
||||
sent. */
|
||||
check("push: the sender prefers the device's own reminder text", (() => {
|
||||
const message = { title: "Own title", body: "Own body", url: "/review" };
|
||||
const payload = reminderPayload({ message }, {});
|
||||
return payload.title === "Own title" && payload.body === "Own body" && payload.url === "/review";
|
||||
})());
|
||||
check("push: the sender falls back to the generic lines", (() => {
|
||||
const payload = reminderPayload({}, { title: "Fallback", body: "Fallback body" });
|
||||
return payload.title === "Fallback" && payload.body === "Fallback body" && payload.url === "/";
|
||||
})());
|
||||
check("push: the sender never sends a reminder link off the app", (() => {
|
||||
const payload = reminderPayload({ message: { title: "T", body: "B", url: "https://elsewhere.example/" } }, {});
|
||||
return payload.url === "/";
|
||||
})());
|
||||
}
|
||||
} finally {
|
||||
app.stop();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue