Files
medassist-ng/backend/src/test/settings.test.ts
T
Daniel Volz 908e4e724f fix: remove dead shareStockStatus gating from shared medication overview (#436)
The shareStockStatus UI toggle was replaced by shareMedicationOverview in
commit e0fb77d, but the backend gating logic was left intact. Users who
had previously set shareStockStatus=false were stuck with empty stock
values ('-') on the shared medication overview with no UI to change it.

- Remove showStockStatus parameter from buildSharedMedicationOverview()
- Remove visibility gating that nullified stock fields
- Remove shareStockStatus from settings API responses and PUT schema
- Remove shareStockStatus from frontend types, hooks, and context
- Clean up all related test fixtures and dead test cases
- DB column share_stock_status retained (never remove columns)
2026-03-15 19:27:39 +01:00

683 lines
20 KiB
TypeScript

/**
* Tests for /settings API endpoints.
* Tests user settings CRUD operations.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
clearTestData,
closeTestApp,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
// =============================================================================
// Route Registration
// =============================================================================
async function registerSettingsRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /settings - Get user settings
app.get("/settings", async (_request, _reply) => {
const userId = 1;
const result = await client.execute({
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
args: [userId],
});
if (result.rows.length === 0) {
// Return defaults
return {
emailEnabled: false,
notificationEmail: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrEnabled: false,
shoutrrrUrl: "",
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
};
}
const s = result.rows[0];
return {
emailEnabled: Boolean(s.email_enabled),
notificationEmail: s.notification_email || "",
emailStockReminders: Boolean(s.email_stock_reminders),
emailIntakeReminders: Boolean(s.email_intake_reminders),
shoutrrrEnabled: Boolean(s.shoutrrr_enabled),
shoutrrrUrl: s.shoutrrr_url || "",
shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders),
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,
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
};
});
// PUT /settings - Update user settings
app.put<{
Body: {
emailEnabled?: boolean;
notificationEmail?: string;
emailStockReminders?: boolean;
emailIntakeReminders?: boolean;
shoutrrrEnabled?: boolean;
shoutrrrUrl?: string;
shoutrrrStockReminders?: boolean;
shoutrrrIntakeReminders?: boolean;
reminderDaysBefore?: number;
repeatDailyReminders?: boolean;
skipRemindersForTakenDoses?: boolean;
repeatRemindersEnabled?: boolean;
reminderRepeatIntervalMinutes?: number;
maxNaggingReminders?: number;
lowStockDays?: number;
normalStockDays?: number;
highStockDays?: number;
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
};
}>("/settings", async (request, reply) => {
const userId = 1;
const body = request.body || {};
// Validation
if (body.emailEnabled && !body.notificationEmail) {
return reply.status(400).send({ error: "Email address required when email is enabled" });
}
if (body.notificationEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.notificationEmail)) {
return reply.status(400).send({ error: "Invalid email address" });
}
if (body.lowStockDays !== undefined && (body.lowStockDays < 1 || body.lowStockDays > 365)) {
return reply.status(400).send({ error: "lowStockDays must be between 1 and 365" });
}
if (body.language && !["en", "de"].includes(body.language)) {
return reply.status(400).send({ error: "Language must be 'en' or 'de'" });
}
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({
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
args: [userId],
});
if (existing.rows.length === 0) {
// Insert new settings
await client.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email,
email_stock_reminders, email_intake_reminders,
shoutrrr_enabled, shoutrrr_url,
shoutrrr_stock_reminders, 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,
expiry_warning_days, language, stock_calculation_mode, share_stock_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
body.emailEnabled ? 1 : 0,
body.notificationEmail || null,
body.emailStockReminders !== false ? 1 : 0,
body.emailIntakeReminders !== false ? 1 : 0,
body.shoutrrrEnabled ? 1 : 0,
body.shoutrrrUrl || null,
body.shoutrrrStockReminders !== false ? 1 : 0,
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,
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
1,
],
});
} else {
// Update existing settings
await client.execute({
sql: `UPDATE user_settings SET
email_enabled = ?,
notification_email = ?,
email_stock_reminders = ?,
email_intake_reminders = ?,
shoutrrr_enabled = ?,
shoutrrr_url = ?,
shoutrrr_stock_reminders = ?,
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 = ?,
expiry_warning_days = ?,
language = ?,
stock_calculation_mode = ?,
share_stock_status = ?,
updated_at = strftime('%s','now')
WHERE user_id = ?`,
args: [
body.emailEnabled ? 1 : 0,
body.notificationEmail || null,
body.emailStockReminders !== false ? 1 : 0,
body.emailIntakeReminders !== false ? 1 : 0,
body.shoutrrrEnabled ? 1 : 0,
body.shoutrrrUrl || null,
body.shoutrrrStockReminders !== false ? 1 : 0,
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,
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
1,
userId,
],
});
}
return { success: true };
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Settings API", () => {
let ctx: TestContext;
let userId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerSettingsRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
userId = await createTestUser(ctx.client, { username: "testuser" });
});
// ---------------------------------------------------------------------------
// GET /settings
// ---------------------------------------------------------------------------
describe("GET /settings", () => {
it("should return default settings for new user", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.emailEnabled).toBe(false);
expect(data.lowStockDays).toBe(30);
expect(data.normalStockDays).toBe(90);
expect(data.highStockDays).toBe(180);
expect(data.language).toBe("en");
expect(data.stockCalculationMode).toBe("automatic");
});
it("should return saved settings", async () => {
// Create settings first
await setUserSettings(ctx.client, {
userId,
stockCalculationMode: "manual",
lowStockDays: 14,
});
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.stockCalculationMode).toBe("manual");
expect(data.lowStockDays).toBe(14);
});
});
// ---------------------------------------------------------------------------
// PUT /settings
// ---------------------------------------------------------------------------
describe("PUT /settings", () => {
it("should create settings for new user", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
language: "de",
lowStockDays: 14,
stockCalculationMode: "manual",
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify
const result = await ctx.client.execute({
sql: `SELECT language, low_stock_days, stock_calculation_mode FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].language).toBe("de");
expect(result.rows[0].low_stock_days).toBe(14);
expect(result.rows[0].stock_calculation_mode).toBe("manual");
});
it("should update existing settings", async () => {
// Create initial settings
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { language: "en" },
});
// Update
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { language: "de" },
});
expect(response.statusCode).toBe(200);
// Verify
const result = await ctx.client.execute({
sql: `SELECT language FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].language).toBe("de");
});
it("should enable email notifications", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
notificationEmail: "test@example.com",
emailStockReminders: true,
emailIntakeReminders: false,
},
});
expect(response.statusCode).toBe(200);
// Verify
const result = await ctx.client.execute({
sql: `SELECT email_enabled, notification_email, email_stock_reminders, email_intake_reminders
FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].email_enabled).toBe(1);
expect(result.rows[0].notification_email).toBe("test@example.com");
expect(result.rows[0].email_stock_reminders).toBe(1);
expect(result.rows[0].email_intake_reminders).toBe(0);
});
it("should reject email enabled without email address", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Email address required when email is enabled");
});
it("should reject invalid email address", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
notificationEmail: "not-an-email",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid email address");
});
it("should reject invalid lowStockDays", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
lowStockDays: 0,
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("lowStockDays must be between 1 and 365");
});
it("should reject invalid language", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
language: "fr",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Language must be 'en' or 'de'");
});
it("should reject invalid stockCalculationMode", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
stockCalculationMode: "invalid",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("stockCalculationMode must be 'automatic' or 'manual'");
});
it("should enable shoutrrr notifications", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/mytopic",
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
},
});
expect(response.statusCode).toBe(200);
// Verify
const result = await ctx.client.execute({
sql: `SELECT shoutrrr_enabled, shoutrrr_url FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].shoutrrr_enabled).toBe(1);
expect(result.rows[0].shoutrrr_url).toBe("ntfy://ntfy.sh/mytopic");
});
it("should update threshold settings", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
lowStockDays: 14,
normalStockDays: 60,
highStockDays: 120,
expiryWarningDays: 30,
},
});
expect(response.statusCode).toBe(200);
// Verify
const result = await ctx.client.execute({
sql: `SELECT low_stock_days, normal_stock_days, high_stock_days, expiry_warning_days
FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].low_stock_days).toBe(14);
expect(result.rows[0].normal_stock_days).toBe(60);
expect(result.rows[0].high_stock_days).toBe(120);
expect(result.rows[0].expiry_warning_days).toBe(30);
});
});
// ---------------------------------------------------------------------------
// Stock Calculation Mode
// ---------------------------------------------------------------------------
describe("Stock Calculation Mode", () => {
it("should switch to manual mode", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
stockCalculationMode: "manual",
},
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().stockCalculationMode).toBe("manual");
});
it("should switch back to automatic mode", async () => {
// Set to manual first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { stockCalculationMode: "manual" },
});
// Switch back
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { stockCalculationMode: "automatic" },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().stockCalculationMode).toBe("automatic");
});
});
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
// 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);
});
});
});