feat: wire interactive intake reminder actions

This commit is contained in:
Daniel Volz
2026-05-10 23:55:04 +02:00
committed by GitHub
parent aa2313427a
commit b4dc1074e8
2 changed files with 862 additions and 5 deletions
@@ -0,0 +1,715 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockedEnv,
createNotificationActionContextMock,
storeNotificationActionGroupNtfyMessageIdMock,
sendPushNotificationMock,
} = vi.hoisted(() => ({
mockedEnv: {
PUBLIC_APP_URL: undefined as string | undefined,
CORS_ORIGINS: "http://localhost:5173" as string,
},
createNotificationActionContextMock: vi.fn(),
storeNotificationActionGroupNtfyMessageIdMock: vi.fn(),
sendPushNotificationMock: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: () => false,
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock("../db/path-utils.js", () => ({
getDataDir: () => "/tmp",
}));
vi.mock("../db/client.js", () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
},
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("../services/notification-actions-service.js", () => ({
createNotificationActionContext: createNotificationActionContextMock,
storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock,
}));
vi.mock("../services/notifications/delivery.js", () => ({
getSmtpConfig: vi.fn(() => null),
sendEmailNotification: vi.fn(),
sendPushNotification: sendPushNotificationMock,
}));
vi.mock("../services/notifications/state.js", () => ({
updateReminderSentTime: vi.fn(),
updateUserReminderSentTime: vi.fn(),
}));
vi.mock("../utils/scheduler-utils.js", async () => {
const actual = await vi.importActual<typeof import("../utils/scheduler-utils.js")>("../utils/scheduler-utils.js");
const candidate = {
medName: "Calcium",
intakeTime: new Date("2026-01-05T11:15:00.000Z"),
intakeTimeStr: "11:15",
usage: 1,
takenBy: null,
pillWeightMg: null,
doseUnit: "mg",
};
return {
...actual,
getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
getDateLocale: () => "en-US",
parseTakenByJson: () => [],
parseIntakesJson: () => [
{
usage: 1,
every: 1,
start: "2026-01-05T10:45:00.000Z",
takenBy: null,
intakeRemindersEnabled: true,
},
],
getTodaysIntakes: () => [candidate],
getUpcomingIntakes: () => [candidate],
};
});
import { db } from "../db/client.js";
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function mockSelectWhere<T>(result: T) {
return {
from: () => ({
where: async () => result,
}),
} as never;
}
describe("intake reminder scheduler action wiring", () => {
const mockedDb = vi.mocked(db);
let originalTz: string | undefined;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
originalTz = process.env.TZ;
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
mockedEnv.PUBLIC_APP_URL = undefined;
mockedEnv.CORS_ORIGINS = "http://localhost:5173";
createNotificationActionContextMock.mockReset();
storeNotificationActionGroupNtfyMessageIdMock.mockReset();
sendPushNotificationMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
if (originalTz === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTz;
}
});
it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 41,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 11,
publicAppUrl: "https://app.example.com",
language: "en",
actionMode: "full",
doseIds: [expect.stringMatching(/^7-0-/)],
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
clickUrl: "https://app.example.com/api/notification-actions/respond",
sequenceId: "medassist-sequence",
tags: ["pill"],
priority: 3,
})
);
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1");
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("uses view-only actions for grouped intake reminders", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 13,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
{
id: 8,
userId: 13,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
method: "GET",
},
],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
});
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 13,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 13,
publicAppUrl: "https://app.example.com",
language: "en",
actionMode: "view-only",
doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)],
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
method: "GET",
},
],
respondUrl: undefined,
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
sequenceId: undefined,
tags: ["pill"],
priority: 3,
})
);
});
it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => {
createNotificationActionContextMock.mockResolvedValue(null);
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 12,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 12,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 12,
publicAppUrl: undefined,
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: undefined,
respondUrl: undefined,
viewUrl: undefined,
clickUrl: undefined,
tags: ["pill"],
priority: 3,
})
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions")
);
});
it("falls back to push delivery without actions when action context generation fails", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 15,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed"));
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 15,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: undefined,
respondUrl: undefined,
viewUrl: undefined,
clickUrl: undefined,
})
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed"));
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Sending intake reminders without actions after action context failure")
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("logs enriched push delivery failures with action context metadata", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 16,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 52,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 16,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full"));
expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled();
});
it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 17,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 77,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" });
storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed"));
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 17,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77");
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("does not send intake reminders for reminder-enabled medications with empty stock", async () => {
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 14,
name: "Calcium",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 14,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).not.toHaveBeenCalled();
expect(sendPushNotificationMock).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Skipping reminder-enabled medications with empty stock")
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("No reminder-eligible medications with stock remaining")
);
});
});