feat: Nagging reminders with max limit + ENV defaults for settings (#18)
* 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>
This commit is contained in:
@@ -107,6 +107,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
@@ -556,6 +560,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
console.error("GET /settings error:", response.body);
|
||||
}
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
// Check default values
|
||||
@@ -720,7 +727,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ status: "ok" });
|
||||
const json = response.json();
|
||||
expect(json.status).toBe("ok");
|
||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1138,7 +1148,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ status: "ok" });
|
||||
const json = response.json();
|
||||
expect(json.status).toBe("ok");
|
||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
|
||||
@@ -94,6 +94,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
getUpcomingIntakes,
|
||||
getTodaysIntakes,
|
||||
createDefaultReminderState,
|
||||
createDefaultIntakeReminderState,
|
||||
parseReminderState,
|
||||
@@ -381,6 +382,94 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
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", () => {
|
||||
@@ -399,7 +488,7 @@ describe("Scheduler Utils - State Management", () => {
|
||||
describe("createDefaultIntakeReminderState", () => {
|
||||
it("should create default intake reminder state", () => {
|
||||
const state = createDefaultIntakeReminderState();
|
||||
expect(state.sentReminders).toEqual([]);
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,62 +528,91 @@ describe("Scheduler Utils - State Management", () => {
|
||||
});
|
||||
|
||||
describe("parseIntakeReminderState", () => {
|
||||
it("should parse valid JSON", () => {
|
||||
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(state.sentReminders).toEqual(["med1:123", "med2:456"]);
|
||||
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.sentReminders).toEqual([]);
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle missing sentReminders", () => {
|
||||
it("should handle missing reminders field", () => {
|
||||
const state = parseIntakeReminderState("{}");
|
||||
expect(state.sentReminders).toEqual([]);
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanOldIntakeReminders", () => {
|
||||
it("should remove entries older than maxAgeMs", () => {
|
||||
const now = Date.now();
|
||||
const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago
|
||||
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 reminders = [
|
||||
`med1:${oldTimestamp}`,
|
||||
`med2:${recentTimestamp}`,
|
||||
];
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const cleaned = cleanOldIntakeReminders(reminders, 24 * 60 * 60 * 1000);
|
||||
const reminders = {
|
||||
[`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 },
|
||||
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
|
||||
};
|
||||
|
||||
expect(cleaned).toHaveLength(1);
|
||||
expect(cleaned[0]).toContain("med2");
|
||||
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||
|
||||
expect(Object.keys(cleaned)).toHaveLength(1);
|
||||
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should keep all entries if none are old", () => {
|
||||
const now = Date.now();
|
||||
const reminders = [
|
||||
`med1:${now - 1000}`,
|
||||
`med2:${now - 2000}`,
|
||||
];
|
||||
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 cleaned = cleanOldIntakeReminders(reminders);
|
||||
expect(cleaned).toHaveLength(2);
|
||||
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 array", () => {
|
||||
const cleaned = cleanOldIntakeReminders([]);
|
||||
expect(cleaned).toEqual([]);
|
||||
it("should handle empty reminders", () => {
|
||||
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
|
||||
expect(cleaned).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle malformed entries (invalid timestamp)", () => {
|
||||
const reminders = ["med1:invalid", "med2:notanumber"];
|
||||
const cleaned = cleanOldIntakeReminders(reminders);
|
||||
// NaN from parseInt will cause these to be filtered out (0 < cutoff)
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrrIntakeReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
@@ -62,6 +66,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
|
||||
reminderDaysBefore: s.reminder_days_before,
|
||||
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
|
||||
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses ?? false),
|
||||
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled ?? false),
|
||||
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
|
||||
maxNaggingReminders: s.max_nagging_reminders ?? 5,
|
||||
lowStockDays: s.low_stock_days,
|
||||
normalStockDays: s.normal_stock_days,
|
||||
highStockDays: s.high_stock_days,
|
||||
@@ -84,6 +92,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrrIntakeReminders?: boolean;
|
||||
reminderDaysBefore?: number;
|
||||
repeatDailyReminders?: boolean;
|
||||
skipRemindersForTakenDoses?: boolean;
|
||||
repeatRemindersEnabled?: boolean;
|
||||
reminderRepeatIntervalMinutes?: number;
|
||||
maxNaggingReminders?: number;
|
||||
lowStockDays?: number;
|
||||
normalStockDays?: number;
|
||||
highStockDays?: number;
|
||||
@@ -111,6 +123,12 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
|
||||
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
|
||||
}
|
||||
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
|
||||
return reply.status(400).send({ error: "reminderRepeatIntervalMinutes must be between 5 and 480" });
|
||||
}
|
||||
if (body.maxNaggingReminders !== undefined && (body.maxNaggingReminders < 1 || body.maxNaggingReminders > 20)) {
|
||||
return reply.status(400).send({ error: "maxNaggingReminders must be between 1 and 20" });
|
||||
}
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await client.execute({
|
||||
@@ -126,10 +144,11 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
email_stock_reminders, email_intake_reminders,
|
||||
shoutrrr_enabled, shoutrrr_url,
|
||||
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
|
||||
reminder_days_before, repeat_daily_reminders,
|
||||
reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses,
|
||||
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||
low_stock_days, normal_stock_days, high_stock_days,
|
||||
expiry_warning_days, language, stock_calculation_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
body.emailEnabled ? 1 : 0,
|
||||
@@ -142,6 +161,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
@@ -164,6 +187,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrr_intake_reminders = ?,
|
||||
reminder_days_before = ?,
|
||||
repeat_daily_reminders = ?,
|
||||
skip_reminders_for_taken_doses = ?,
|
||||
repeat_reminders_enabled = ?,
|
||||
reminder_repeat_interval_minutes = ?,
|
||||
max_nagging_reminders = ?,
|
||||
low_stock_days = ?,
|
||||
normal_stock_days = ?,
|
||||
high_stock_days = ?,
|
||||
@@ -183,6 +210,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
@@ -507,4 +538,137 @@ describe("Settings API", () => {
|
||||
expect(getResponse.json().stockCalculationMode).toBe("automatic");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repeat Reminders & Skip Reminders Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Repeat Reminders Settings", () => {
|
||||
it("should enable repeat reminders with interval", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||
expect(settings.reminderRepeatIntervalMinutes).toBe(10);
|
||||
});
|
||||
|
||||
it("should validate repeat interval range", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 2,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 500,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should validate max nagging reminders range", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 0,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 25,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
// Valid values should work
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 10,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.maxNaggingReminders).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skip Reminders for Taken Doses", () => {
|
||||
it("should enable and disable skip reminders setting", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
skipRemindersForTakenDoses: true,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
skipRemindersForTakenDoses: false,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("should work with repeat reminders enabled", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 5,
|
||||
skipRemindersForTakenDoses: true,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||
expect(settings.reminderRepeatIntervalMinutes).toBe(5);
|
||||
expect(settings.skipRemindersForTakenDoses).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user