feat: wire interactive intake reminder actions
This commit is contained in:
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user