import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Import actual utility functions from scheduler-utils import { type Blister, calculateDailyUsage, calculateDepletionInfo, cleanOldIntakeReminders, createDefaultIntakeReminderState, createDefaultReminderState, formatInTimezone, getCurrentHourInTimezone, getMsUntilNextCheck, getNextScheduledTime, getTimezone, getTodayInTimezone, getTodaysIntakes, getUpcomingIntakes, type Intake, parseBlisters, parseIntakeReminderState, parseReminderState, parseTakenByJson, personTakesMedication, } 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 - 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 - 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 skip blisters with zero interval", () => { 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).toEqual([]); }); 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); }); }); });