feat: restore ntfy interactive settings test delivery support

Squash merge PR #591: feat: restore ntfy interactive settings test delivery support
This commit is contained in:
Daniel Volz
2026-05-10 23:04:24 +02:00
committed by GitHub
parent d99bc3d99e
commit 255746d9f5
4 changed files with 421 additions and 3 deletions
+7 -1
View File
@@ -13,6 +13,12 @@ PORT=3000
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn
# Public base URL used for notification action links.
# Required for intake reminder action buttons/links.
# PUBLIC_APP_URL=https://medassist.example.com
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
@@ -149,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+3 -2
View File
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
export type UserSettings = {
userId: number;
@@ -81,7 +82,7 @@ export function getNotificationProvider(url: string): string {
if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover";
if (url.startsWith("ntfy://")) return "ntfy";
if (isNtfyNotificationUrl(url)) return "ntfy";
try {
const parsed = new URL(url);
@@ -231,7 +232,7 @@ export function sanitizeNotificationUrl(
return { url: discordWebhookUrl, isNtfy: false };
}
const isNtfy = urlStr.startsWith("ntfy://");
const isNtfy = isNtfyNotificationUrl(urlStr);
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
getNotificationActionLabels,
isNtfyNotificationUrl,
type PushNotificationAction,
renderNotificationActionPayload,
} from "../services/notifications/action-renderer.js";
function decodeRfc2047Base64(value: string): string {
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
if (!match) {
return value;
}
return Buffer.from(match[1], "base64").toString("utf-8");
}
const actions: PushNotificationAction[] = [
{
kind: "taken",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
},
{
kind: "skip",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
];
describe("notification action renderer", () => {
it("builds ntfy native actions without duplicate click headers", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
tags: ["pill"],
priority: 4,
sequenceId: "medassist-sequence",
});
expect(result.message).toBe("Body");
expect(result.headers).toMatchObject({
Tags: "pill",
Priority: "4",
"X-Sequence-ID": "medassist-sequence",
});
expect(result.headers.Click).toBeUndefined();
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
expect(parsedActions).toEqual([
{
action: "http",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
{
action: "http",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "View",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("keeps the ntfy click header when there are no native actions", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
});
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
});
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
expect(result.message).toBe("Body");
expect(result.headers.Actions).toBeTruthy();
expect(result.message).not.toContain("Respond:");
});
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "taken",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
},
],
});
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
{
action: "http",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
]);
});
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "skip",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{
kind: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
method: "GET",
},
],
});
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
{
action: "http",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("uses consistent action-form labels for English and German", () => {
expect(getNotificationActionLabels("en")).toEqual({
taken: "Take",
skip: "Skip",
respond: "Respond",
view: "View",
});
expect(getNotificationActionLabels("de")).toEqual({
taken: "Einnehmen",
skip: "Überspringen",
respond: "Antworten",
view: "Öffnen",
});
});
it("appends respond and view fallback links for non-ntfy providers", () => {
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(result.headers).toEqual({});
expect(result.message).toBe(
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
);
});
});
@@ -0,0 +1,225 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
PUBLIC_APP_URL: "https://app.example.com",
CORS_ORIGINS: "http://localhost:5173,http://localhost:4173",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import(
"../services/notification-actions-service.js"
);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
function extractToken(url: string): string {
return url.split("/").at(-1) ?? "";
}
async function clearTables() {
await testClient.execute("DELETE FROM notification_action_tokens");
await testClient.execute("DELETE FROM notification_action_groups");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
describe("notification-actions-service", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
});
afterAll(() => {
testClient.close();
});
beforeEach(async () => {
await clearTables();
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173";
});
it("creates a notification action group with hashed tokens and app/view links", async () => {
const userId = await createUser("notify-actions-user");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: expect.stringContaining("/api/notification-actions/"),
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
sequenceId: expect.stringMatching(/^medassist-/),
});
expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]);
const groups = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(Number(groups.rows[0].count)).toBe(1);
const tokenRows = await testClient.execute({
sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC",
});
expect(tokenRows.rows).toHaveLength(3);
const respondToken = extractToken(context!.respondUrl!);
const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond");
expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" }));
expect(respondRow?.token_hash).not.toBe(respondToken);
const record = await getNotificationActionTokenRecord(respondToken);
expect(record).toMatchObject({
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
});
it("creates a view-only context without mutation tokens", async () => {
const userId = await createUser("notify-actions-view-only");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Grouped reminder",
message: "Open the dashboard for details",
doseIds: ["9-0-1736064000000", "10-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
actionMode: "view-only",
});
expect(context).toEqual({
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
method: "GET",
},
],
});
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
expect(Number(groups.rows[0].count)).toBe(0);
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(0);
});
it("reuses an unresolved active group for the same dose set and schedule", async () => {
const userId = await createUser("notify-actions-reuse");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const first = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
const second = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(second?.sequenceId).toBe(first?.sequenceId);
const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups");
expect(groups.rows).toHaveLength(1);
expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId }));
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(6);
});
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
const userId = await createUser("notify-actions-mobile");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
mockedEnv.PUBLIC_APP_URL = "http://localhost:5173";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173";
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`,
viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!));
expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000");
});
it("falls back to the date view when dose ids do not contain a medication id", async () => {
const userId = await createUser("notify-actions-fallback");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["invalid-dose-id"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id");
});
});