Files
medassist-ng/backend/src/test/services.test.ts
T
2026-03-20 14:58:25 +01:00

854 lines
28 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Import actual utility functions from scheduler-utils
import {
type Blister,
calculateDailyUsage,
calculateDepletionInfo,
cleanOldIntakeReminders,
countScheduledOccurrencesInRange,
createDefaultIntakeReminderState,
createDefaultReminderState,
forEachScheduledOccurrenceInRange,
formatInTimezone,
getAverageOccurrencesPerDay,
getCurrentHourInTimezone,
getMaxScheduledGapDays,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
normalizeIntake,
parseBlisters,
parseIntakeReminderState,
parseIntakesJson,
parseReminderState,
parseTakenByJson,
personTakesMedication,
type Weekday,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
function blisterToIntake(blister: Blister, takenBy: string | null = null, intakeRemindersEnabled = false): Intake {
return {
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy,
intakeRemindersEnabled,
};
}
describe("Scheduler Utils - Timezone Functions", () => {
let originalTz: string | undefined;
beforeEach(() => {
originalTz = process.env.TZ;
});
afterEach(() => {
if (originalTz !== undefined) {
process.env.TZ = originalTz;
} else {
delete process.env.TZ;
}
});
describe("getTimezone", () => {
it("should return TZ env variable when set", () => {
process.env.TZ = "America/New_York";
expect(getTimezone()).toBe("America/New_York");
});
it("should return UTC when TZ not set", () => {
delete process.env.TZ;
expect(getTimezone()).toBe("UTC");
});
it("should handle Europe/Berlin timezone", () => {
process.env.TZ = "Europe/Berlin";
expect(getTimezone()).toBe("Europe/Berlin");
});
});
describe("formatInTimezone", () => {
it("should format date in given timezone", () => {
const date = new Date("2025-12-30T12:00:00.000Z");
const formatted = formatInTimezone(date, "UTC");
expect(formatted).toContain("30");
expect(formatted).toContain("12");
});
it("should use process.env.TZ when no tz provided", () => {
process.env.TZ = "UTC";
const date = new Date("2025-12-30T15:30:00.000Z");
const formatted = formatInTimezone(date);
expect(formatted).toContain("15:30");
});
});
describe("getCurrentHourInTimezone", () => {
it("should return a valid hour (0-23)", () => {
process.env.TZ = "UTC";
const hour = getCurrentHourInTimezone();
expect(hour).toBeGreaterThanOrEqual(0);
expect(hour).toBeLessThanOrEqual(23);
});
it("should respect timezone parameter", () => {
const hourUtc = getCurrentHourInTimezone("UTC");
expect(hourUtc).toBeGreaterThanOrEqual(0);
expect(hourUtc).toBeLessThanOrEqual(23);
});
});
describe("getTodayInTimezone", () => {
it("should return date in YYYY-MM-DD format", () => {
process.env.TZ = "UTC";
const today = getTodayInTimezone();
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("should return a valid date", () => {
process.env.TZ = "UTC";
const today = getTodayInTimezone();
const date = new Date(today);
expect(date.toString()).not.toBe("Invalid Date");
});
it("should respect timezone parameter", () => {
const today = getTodayInTimezone("UTC");
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe("getNextScheduledTime", () => {
it("should return a Date object", () => {
const next = getNextScheduledTime(6, "UTC");
expect(next).toBeInstanceOf(Date);
});
it("should return a time in the future", () => {
// Use hour 0 to minimize chance of being exactly at that hour
const next = getNextScheduledTime(0, "UTC");
expect(next.getTime()).toBeGreaterThan(Date.now() - 60 * 60 * 1000); // Within 1 hour of now or future
});
it("should schedule for the given hour", () => {
const next = getNextScheduledTime(10, "UTC");
const hourInUtc = parseInt(next.toLocaleString("en-US", { timeZone: "UTC", hour: "numeric", hour12: false }), 10);
expect(hourInUtc).toBe(10);
});
});
describe("getMsUntilNextCheck", () => {
it("should return a positive number (or very small negative within tolerance)", () => {
const ms = getMsUntilNextCheck(6, "UTC");
// Could be slightly negative if we're right at the scheduled time
expect(ms).toBeGreaterThan(-60000);
});
it("should be less than or equal to 24 hours", () => {
const ms = getMsUntilNextCheck(6, "UTC");
const maxMs = 24 * 60 * 60 * 1000 + 60000; // 24h + 1min tolerance
expect(ms).toBeLessThanOrEqual(maxMs);
});
});
});
describe("Scheduler Utils - Sharing", () => {
it("treats the all-share sentinel as matching intake-specific assignees", () => {
const intakes = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, "Max")];
expect(personTakesMedication("all", [], intakes)).toBe(true);
expect(personTakesMedication("Max", [], intakes)).toBe(true);
expect(personTakesMedication("Anna", [], intakes)).toBe(false);
});
});
describe("Scheduler Utils - Blister Parsing", () => {
describe("parseBlisters", () => {
it("should parse valid blister JSON arrays", () => {
const row = {
usageJson: "[1, 2, 0.5]",
everyJson: "[1, 2, 7]",
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
};
const blisters = parseBlisters(row);
expect(blisters).toHaveLength(3);
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
expect(blisters[1]).toEqual({ usage: 2, every: 2, start: "2025-01-01T20:00" });
expect(blisters[2]).toEqual({ usage: 0.5, every: 7, start: "2025-01-01T12:00" });
});
it("should handle arrays of different lengths (use shortest)", () => {
const row = {
usageJson: "[1, 2]",
everyJson: "[1]",
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
};
const blisters = parseBlisters(row);
expect(blisters).toHaveLength(1);
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
});
it("should return empty array for empty JSON arrays", () => {
const row = {
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
};
const blisters = parseBlisters(row);
expect(blisters).toHaveLength(0);
});
it("should return empty array for invalid JSON", () => {
const row = {
usageJson: "invalid",
everyJson: "[1]",
startJson: '["2025-01-01T08:00"]',
};
const blisters = parseBlisters(row);
expect(blisters).toHaveLength(0);
});
it("should return empty array for non-array JSON", () => {
const row = {
usageJson: '{"usage": 1}',
everyJson: "[1]",
startJson: '["2025-01-01T08:00"]',
};
const blisters = parseBlisters(row);
expect(blisters).toHaveLength(0);
});
});
describe("parseTakenByJson", () => {
it("should return empty array for null input", () => {
expect(parseTakenByJson(null)).toEqual([]);
});
it("should return empty array for undefined input", () => {
expect(parseTakenByJson(undefined)).toEqual([]);
});
it("should return empty array for empty string", () => {
expect(parseTakenByJson("")).toEqual([]);
});
it("should parse valid JSON array of strings", () => {
expect(parseTakenByJson('["Alice", "Bob"]')).toEqual(["Alice", "Bob"]);
});
it("should return empty array for empty JSON array", () => {
expect(parseTakenByJson("[]")).toEqual([]);
});
it("should filter out non-string values", () => {
expect(parseTakenByJson('[1, "Alice", null, "Bob", true]')).toEqual(["Alice", "Bob"]);
});
it("should filter out empty strings", () => {
expect(parseTakenByJson('["Alice", "", "Bob", " "]')).toEqual(["Alice", "Bob"]);
});
it("should return empty array for invalid JSON", () => {
expect(parseTakenByJson("invalid json")).toEqual([]);
});
it("should return empty array for non-array JSON", () => {
expect(parseTakenByJson('{"name": "Alice"}')).toEqual([]);
expect(parseTakenByJson('"Alice"')).toEqual([]);
expect(parseTakenByJson("123")).toEqual([]);
});
});
});
describe("Scheduler Utils - Intake Schedule Normalization", () => {
describe("normalizeIntake", () => {
it("keeps interval schedules backward-compatible by default", () => {
const intake = normalizeIntake({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
});
expect(intake).toMatchObject({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
});
});
it("normalizes malformed weekday schedules to the start date weekday", () => {
const intake = normalizeIntake({
usage: 1,
every: 99,
start: "2025-01-06T08:00:00",
scheduleMode: "weekdays",
weekdays: ["bogus", null],
});
expect(intake.scheduleMode).toBe("weekdays");
expect(intake.every).toBe(1);
expect(intake.weekdays).toEqual(["mon"]);
});
});
describe("parseIntakesJson", () => {
it("falls back to legacy interval data when unified intakes are absent", () => {
const intakes = parseIntakesJson(
null,
{
usageJson: "[1,2]",
everyJson: "[1,3]",
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
},
true
);
expect(intakes).toEqual([
{
usage: 1,
every: 1,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
{
usage: 2,
every: 3,
start: "2025-01-02T20:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
]);
});
});
});
describe("Scheduler Utils - Daily Usage Calculation", () => {
describe("calculateDailyUsage", () => {
it("should calculate daily usage for single daily dose", () => {
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
expect(calculateDailyUsage(blisters)).toBe(1);
});
it("should calculate daily usage for twice daily dose", () => {
const blisters: Blister[] = [
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
];
expect(calculateDailyUsage(blisters)).toBe(2);
});
it("should calculate daily usage for weekly dose", () => {
const blisters: Blister[] = [{ usage: 1, every: 7, start: "2025-01-01T08:00" }];
expect(calculateDailyUsage(blisters)).toBeCloseTo(1 / 7, 5);
});
it("should calculate daily usage for mixed schedules", () => {
const blisters: Blister[] = [
{ usage: 2, every: 1, start: "2025-01-01T08:00" }, // 2 per day
{ usage: 1, every: 2, start: "2025-01-01T20:00" }, // 0.5 per day
];
expect(calculateDailyUsage(blisters)).toBe(2.5);
});
it("should return 0 for empty blisters", () => {
expect(calculateDailyUsage([])).toBe(0);
});
it("should handle fractional usage amounts", () => {
const blisters: Blister[] = [{ usage: 0.5, every: 1, start: "2025-01-01T08:00" }];
expect(calculateDailyUsage(blisters)).toBe(0.5);
});
});
});
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
it("calculates average usage and gap length for weekday schedules", () => {
const weekdaysSchedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
});
it("finds the next weekday occurrence after a given timestamp", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
});
it("iterates weekday occurrences in canonical order within a range", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
};
const occurrences: number[] = [];
forEachScheduledOccurrenceInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime(),
(occurrenceMs) => {
occurrences.push(occurrenceMs);
}
);
expect(occurrences.sort((a, b) => a - b)).toEqual([
new Date(2025, 0, 6, 9, 0, 0).getTime(),
new Date(2025, 0, 8, 9, 0, 0).getTime(),
new Date(2025, 0, 10, 9, 0, 0).getTime(),
]);
expect(
countScheduledOccurrencesInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime()
)
).toEqual({
count: 3,
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
});
});
});
describe("Scheduler Utils - Depletion Calculation", () => {
describe("calculateDepletionInfo", () => {
it("should calculate days left correctly", () => {
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
expect(result.daysLeft).toBe(30);
expect(result.depletionDate).toBeTruthy();
});
it("should calculate days left with multiple doses per day", () => {
const blisters: Blister[] = [
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
];
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
expect(result.daysLeft).toBe(15);
});
it("should return null when no blisters configured", () => {
const result = calculateDepletionInfo({ count: 30, blisters: [] }, "en");
expect(result.daysLeft).toBeNull();
expect(result.depletionDate).toBeNull();
});
it("should return null when usage is zero", () => {
const blisters: Blister[] = [{ usage: 0, every: 1, start: "2025-01-01T08:00" }];
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
expect(result.daysLeft).toBeNull();
});
it("should floor the days left", () => {
// 10 pills / 3 per day = 3.33... days -> floors to 3
const blisters: Blister[] = [{ usage: 3, every: 1, start: "2025-01-01T08:00" }];
const result = calculateDepletionInfo({ count: 10, blisters }, "en");
expect(result.daysLeft).toBe(3);
});
it("should handle German language", () => {
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
const result = calculateDepletionInfo({ count: 10, blisters }, "de");
expect(result.depletionDate).toBeTruthy();
// German locale should be used
});
});
});
describe("Scheduler Utils - Upcoming Intakes", () => {
describe("getUpcomingIntakes", () => {
it("should return empty array when no intakes in window", () => {
// With parseLocalDateTime, times are treated as local - use same format for consistency
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
// Set "now" to a time far from any scheduled intake (12:00 local)
const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
});
it("should find intake within reminder window", () => {
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
const intakes: Intake[] = [blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:00:00" }, "Alice")];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], 500, "en-US", "UTC", now);
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("TestMed");
expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toBe("Alice");
expect(result[0].pillWeightMg).toBe(500);
});
it("should treat zero interval as a daily fallback", () => {
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
medName: "TestMed",
usage: 1,
takenBy: null,
});
});
it("should handle multiple blisters", () => {
// Two intakes at 08:00 and 08:01 local
const intakes: Intake[] = [
blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }),
blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }),
];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Both should be found as they're within the window
expect(result.length).toBeGreaterThanOrEqual(1);
});
it("should catch up missed advance reminder when notify window passed but intake still future", () => {
// Intake at 15:57, reminder 15 min before = 15:42
// Scheduler was down at 15:42, now running at 15:50 (intake still in future)
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T15:57:00" })];
// "now" = 15:50 local time on the same day — past the 15:42 notify window, but before 15:57 intake
const now = new Date(2025, 0, 1, 15, 50, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should still return the intake as a catch-up advance reminder
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("TestMed");
expect(result[0].usage).toBe(1);
});
it("should catch up missed advance reminder even 1 minute before intake", () => {
// Intake at 08:00, reminder at 07:45. Scheduler catches up at 07:59.
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 59, 30).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toHaveLength(1);
});
it("should not catch up for intakes already in the past", () => {
// Intake at 08:00, reminder at 07:45. Now = 08:05 (intake already past).
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 8, 5, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should NOT return — intake is past, handled by getTodaysIntakes instead
expect(result).toHaveLength(0);
});
it("should catch up for recurring intake on later day", () => {
// Intake started Jan 1 at 10:00, every 1 day. Now = Jan 3 at 09:50 (past notify, before intake)
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T10:00:00" })];
const now = new Date(2025, 0, 3, 9, 50, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should return today's occurrence via catch-up
expect(result).toHaveLength(1);
// The intake time should be Jan 3 at 10:00
expect(result[0].intakeTime.getHours()).toBe(10);
expect(result[0].intakeTime.getDate()).toBe(3);
});
});
describe("getTodaysIntakes", () => {
it("should return all intakes for today", () => {
// Daily medication at 08:00 starting yesterday
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" })];
// Get intakes for today (today's intake should be at 08:00 local)
const result = getTodaysIntakes("TestMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(1);
const intake = result.find((i) => i.intakeTime.getHours() === 8);
expect(intake).toBeDefined();
expect(intake?.medName).toBe("TestMed");
expect(intake?.usage).toBe(1);
});
it("should include past intakes from today", () => {
// Medication at 00:01 today (definitely in the past)
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 1, 0, 0);
const intakes: Intake[] = [
blisterToIntake(
{
usage: 2,
every: 1,
start: todayMidnight.toISOString(),
},
"Bob"
),
];
const result = getTodaysIntakes("PastMed", intakes, [], 250, "en-US", "UTC");
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("PastMed");
expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toBe("Bob");
expect(result[0].pillWeightMg).toBe(250);
});
it("should handle multiple intakes per day", () => {
// Two intakes today: morning and evening
const today = new Date();
const morning = new Date(today);
morning.setUTCHours(8, 0, 0, 0);
const evening = new Date(today);
evening.setUTCHours(20, 0, 0, 0);
const intakes: Intake[] = [
blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }),
blisterToIntake({ usage: 1, every: 1, start: evening.toISOString() }),
];
const result = getTodaysIntakes("MultiMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(2);
});
it("should not include intakes from other days", () => {
// Weekly medication on a different day of week
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
const intakes: Intake[] = [
blisterToIntake({
usage: 1,
every: 7,
start: lastWeek.toISOString(),
}),
];
// If today is not the same day of week, should return empty
const result = getTodaysIntakes("WeeklyMed", intakes, [], null, "en-US", "UTC");
// This test might return 0 or 1 depending on the day
expect(Array.isArray(result)).toBe(true);
});
it("should handle local time correctly (ignore Z suffix)", () => {
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
const intakes: Intake[] = [
blisterToIntake({
usage: 1,
every: 1,
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time
}),
];
const result = getTodaysIntakes("TzMed", intakes, [], null, "de-DE", "Europe/Berlin");
expect(Array.isArray(result)).toBe(true);
if (result.length > 0) {
// The intakeTimeStr should be a valid time format (HH:MM)
// Exact value depends on server timezone vs target timezone offset
expect(result[0].intakeTimeStr).toMatch(/^\d{2}:\d{2}$/);
}
});
});
});
describe("Scheduler Utils - State Management", () => {
describe("createDefaultReminderState", () => {
it("should create default reminder state", () => {
const state = createDefaultReminderState();
expect(state.lastAutoEmailSent).toBeNull();
expect(state.lastAutoEmailDate).toBeNull();
expect(state.notifiedMedications).toEqual([]);
expect(state.nextScheduledCheck).toBeNull();
expect(state.lastNotificationType).toBeNull();
expect(state.lastNotificationChannel).toBeNull();
});
});
describe("createDefaultIntakeReminderState", () => {
it("should create default intake reminder state", () => {
const state = createDefaultIntakeReminderState();
expect(state.reminders).toEqual({});
});
});
describe("parseReminderState", () => {
it("should parse valid JSON", () => {
const json = JSON.stringify({
lastAutoEmailSent: "2025-12-30T10:00:00.000Z",
lastAutoEmailDate: "2025-12-30",
notifiedMedications: ["med1", "med2"],
nextScheduledCheck: "2025-12-31T06:00:00.000Z",
lastNotificationType: "stock",
lastNotificationChannel: "email",
});
const state = parseReminderState(json);
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
expect(state.lastAutoEmailDate).toBe("2025-12-30");
expect(state.notifiedMedications).toEqual(["med1", "med2"]);
expect(state.lastNotificationType).toBe("stock");
expect(state.lastNotificationChannel).toBe("email");
});
it("should handle partial state with defaults", () => {
const json = JSON.stringify({ lastAutoEmailSent: "2025-12-30T10:00:00.000Z" });
const state = parseReminderState(json);
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
expect(state.lastAutoEmailDate).toBeNull();
expect(state.notifiedMedications).toEqual([]);
});
it("should return defaults for invalid JSON", () => {
const state = parseReminderState("invalid json {{{");
expect(state.lastAutoEmailSent).toBeNull();
expect(state.notifiedMedications).toEqual([]);
});
});
describe("parseIntakeReminderState", () => {
it("should parse valid new format JSON", () => {
const json = JSON.stringify({
reminders: {
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 },
},
});
const state = parseIntakeReminderState(json);
expect(Object.keys(state.reminders)).toHaveLength(2);
expect(state.reminders["med1:123"].sendCount).toBe(2);
});
it("should convert old array format to new format", () => {
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
const state = parseIntakeReminderState(json);
expect(Object.keys(state.reminders)).toHaveLength(2);
expect(state.reminders["med1:123"]).toBeDefined();
expect(state.reminders["med1:123"].sendCount).toBe(1);
});
it("should return defaults for invalid JSON", () => {
const state = parseIntakeReminderState("invalid");
expect(state.reminders).toEqual({});
});
it("should handle missing reminders field", () => {
const state = parseIntakeReminderState("{}");
expect(state.reminders).toEqual({});
});
});
describe("cleanOldIntakeReminders", () => {
it("should remove entries from past days (timezone-aware)", () => {
const tz = "Europe/Berlin";
const now = new Date();
const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
today.setHours(12, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const reminders = {
[`med1:${yesterday.getTime()}`]: {
firstSentAt: yesterday.getTime(),
lastSentAt: yesterday.getTime(),
sendCount: 1,
},
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
};
const cleaned = cleanOldIntakeReminders(reminders, tz);
expect(Object.keys(cleaned)).toHaveLength(1);
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
});
it("should keep all entries from today", () => {
const tz = "Europe/Berlin";
const now = new Date();
const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
morning.setHours(8, 0, 0, 0);
const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
evening.setHours(20, 0, 0, 0);
const reminders = {
[`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 },
[`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 },
};
const cleaned = cleanOldIntakeReminders(reminders, tz);
expect(Object.keys(cleaned)).toHaveLength(2);
});
it("should handle empty reminders", () => {
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
expect(cleaned).toEqual({});
});
it("should handle malformed entries (invalid timestamp in key)", () => {
const reminders = {
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 },
};
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
expect(Object.keys(cleaned)).toHaveLength(0);
});
});
});