d0a40bde88
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds * docs: add testing and CI/CD documentation * security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting) - Add URL validation to prevent SSRF attacks on notification endpoints - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Block localhost and internal hostnames - Only allow HTTP/HTTPS protocols - Add HTML escaping for medication names in email templates (XSS) - Add stricter rate limiting for auth routes (5 req/15min for login/register) - Add SSRF protection tests (405 tests total) * security: add rate limiting to remaining auth routes * chore: add CodeQL config to suppress rate-limit false positives Rate limiting IS implemented via @fastify/rate-limit plugin: - Global: 100 req/min (index.ts) - Auth routes: 5-10 req/min via config.rateLimit option CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern. * ci: switch to CodeQL Advanced Setup - Add custom codeql.yml workflow - Configure to use codeql-config.yml - Exclude js/missing-rate-limiting rule (false positive) Rate limiting is implemented via @fastify/rate-limit plugin * ci: add explicit permissions to workflows Fixes CodeQL 'Workflow does not contain permissions' warnings. Sets minimal 'contents: read' at top level. * ci: add manual trigger to CodeQL workflow * ci: add explicit permissions to all workflow jobs * build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together. Updates `esbuild` from 0.21.5 to 0.27.2 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2) Updates `@vitest/coverage-v8` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8) Updates `vitest` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.27.2 dependency-type: indirect - dependency-name: "@vitest/coverage-v8" dependency-version: 4.0.16 dependency-type: direct:development - dependency-name: vitest dependency-version: 4.0.16 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> * docs: add GitHub issue templates - Bug report template with deployment type, browser info, logs - Feature request template with affected area, priority - Config with link to discussions and README - Optimize test.yml to skip tests for non-code changes * Initial plan * Remove database schema duplication by creating shared schema-sql.ts module Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Refactor frontend date formatting to eliminate duplication Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * docs: Add branch protection warning and PR workflow to instructions * ci: remove paths filter from test workflow to fix branch protection * fix: add .js extension to schema-sql imports for ESM compatibility (#15) * feat: add setting to skip reminders for taken doses - Add skipRemindersForTakenDoses setting to database schema - Extend settings API to save and load new setting - Update intake reminder scheduler to filter taken doses - Add frontend toggle in settings with i18n (EN/DE) - Only check doses from today (timezone-aware) - Update all test schemas with new field - All 405 tests passing * feat: add repeat reminders for missed doses - Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings - Refactor intake reminder state from array to object with sendCount tracking - Update scheduler to send repeated reminders at configurable intervals - Only remind for today's doses (timezone-aware filtering) - Add frontend toggle and interval input (5-480 minutes) in settings - Maintain backward compatibility for old state file format - Update all test schemas and assertions - All 406 tests passing * feat: add nagging reminders with max limit and ENV defaults - Add maxNaggingReminders setting to limit repeat reminders (1-20) - Add ENV defaults for all user settings (DEFAULT_*) - Add ALTER TABLE migrations for backward compatibility - Add smtpConfigured/shoutrrrConfigured to health endpoint - Fix Push toggle to allow enabling without existing URL - Disable skip/repeat toggles when no notifications enabled - Add Pocket ID button to registration page - Add getTodaysIntakes() for repeat reminder logic - Update translations (en/de) for new settings - Add comprehensive tests for new features --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
619 lines
22 KiB
TypeScript
619 lines
22 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
|
import { resolve } from "path";
|
|
import { tmpdir } from "os";
|
|
|
|
// Import actual utility functions from scheduler-utils
|
|
import {
|
|
getTimezone,
|
|
formatInTimezone,
|
|
getCurrentHourInTimezone,
|
|
getTodayInTimezone,
|
|
getNextScheduledTime,
|
|
getMsUntilNextCheck,
|
|
parseBlisters,
|
|
parseTakenByJson,
|
|
calculateDailyUsage,
|
|
calculateDepletionInfo,
|
|
getUpcomingIntakes,
|
|
getTodaysIntakes,
|
|
createDefaultReminderState,
|
|
createDefaultIntakeReminderState,
|
|
parseReminderState,
|
|
parseIntakeReminderState,
|
|
cleanOldIntakeReminders,
|
|
type Blister,
|
|
type ReminderState,
|
|
type IntakeReminderState,
|
|
type UpcomingIntake,
|
|
} from "../utils/scheduler-utils.js";
|
|
|
|
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 - 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", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
|
// Set "now" to a time far from any scheduled intake
|
|
const now = new Date("2025-01-01T12:00:00.000Z").getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should find intake within reminder window", () => {
|
|
// Schedule intake at 08:00, check at 07:45 (15 minutes before)
|
|
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
|
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 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).toEqual(["Alice"]);
|
|
expect(result[0].pillWeightMg).toBe(500);
|
|
});
|
|
|
|
it("should skip blisters with zero interval", () => {
|
|
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00.000Z" }];
|
|
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should handle multiple blisters", () => {
|
|
// Two intakes at 08:00 and 08:01
|
|
const blisters: Blister[] = [
|
|
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
|
{ usage: 2, every: 1, start: "2025-01-01T08:01:00.000Z" },
|
|
];
|
|
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
|
|
|
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
|
|
|
// Both should be found as they're within the window
|
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe("getTodaysIntakes", () => {
|
|
it("should return all intakes for today", () => {
|
|
// Daily medication at 08:00 starting yesterday
|
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
|
|
|
// Get intakes for 2025-01-02 (today's intake should be at 08:00)
|
|
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
|
|
|
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
const intake = result.find(i => i.intakeTime.getUTCHours() === 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 blisters: Blister[] = [{
|
|
usage: 2,
|
|
every: 1,
|
|
start: todayMidnight.toISOString()
|
|
}];
|
|
|
|
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].medName).toBe("PastMed");
|
|
expect(result[0].usage).toBe(2);
|
|
expect(result[0].takenBy).toEqual(["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 blisters: Blister[] = [
|
|
{ usage: 1, every: 1, start: morning.toISOString() },
|
|
{ usage: 1, every: 1, start: evening.toISOString() },
|
|
];
|
|
|
|
const result = getTodaysIntakes("MultiMed", blisters, [], 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 blisters: Blister[] = [{
|
|
usage: 1,
|
|
every: 7,
|
|
start: lastWeek.toISOString()
|
|
}];
|
|
|
|
// If today is not the same day of week, should return empty
|
|
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
|
|
|
|
// This test might return 0 or 1 depending on the day
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
it("should handle timezone correctly", () => {
|
|
// 23:00 in Europe/Berlin on a specific date
|
|
const blisters: Blister[] = [{
|
|
usage: 1,
|
|
every: 1,
|
|
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
|
|
}];
|
|
|
|
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
if (result.length > 0) {
|
|
expect(result[0].intakeTimeStr).toContain("23:");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|