feat: backend improvements - reminder tracking, share stock status, planner notifications (#145)

- Separate stock/intake reminder tracking in DB with dedicated columns
- Add shareStockStatus setting to control stock visibility on shared links
- Rewrite planner notification to support both email and Shoutrrr push
- Add push notification footer text for intake and stock reminders
- New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007)
- Update backend i18n with demandCalculator section and critically low text
- Add 514 passing backend tests including new coverage for all changes
This commit is contained in:
Daniel Volz
2026-02-09 19:32:32 +01:00
committed by GitHub
parent 8ff652459d
commit f56f2b7c88
22 changed files with 2815 additions and 286 deletions
+4
View File
@@ -126,11 +126,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+4
View File
@@ -121,11 +121,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+306 -25
View File
@@ -110,11 +110,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -161,21 +165,6 @@ describe("Planner Routes", () => {
});
describe("POST /planner/send-email", () => {
it("should reject request with missing email", async () => {
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
from: "2025-01-01",
until: "2025-01-31",
rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing email or planner data" });
});
it("should reject request with missing rows", async () => {
const response = await app.inject({
method: "POST",
@@ -189,10 +178,16 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing email or planner data" });
expect(response.json()).toEqual({ error: "Missing planner data" });
});
it("should reject when SMTP is not configured", async () => {
it("should return error when no notification channels configured", async () => {
// User settings exist but email/shoutrrr disabled
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`,
args: [999999999],
});
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
@@ -217,7 +212,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "SMTP not configured" });
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should send email successfully when SMTP is configured", async () => {
@@ -226,6 +221,12 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
// Enable email in user settings
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -253,7 +254,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Email sent successfully" });
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
// Cleanup
@@ -267,6 +268,11 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -308,7 +314,7 @@ describe("Planner Routes", () => {
// Check that HTML contains out of stock warning
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.html).toContain("Out of Stock");
expect(mailCall.html).toContain("Empty");
expect(mailCall.html).toContain("1 medication");
delete process.env.SMTP_HOST;
@@ -321,6 +327,11 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockRejectedValueOnce(new Error("Connection refused"));
const response = await app.inject({
@@ -347,7 +358,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toContain("Failed to send email");
expect(response.json().error).toContain("Email:");
expect(response.json().error).toContain("Connection refused");
delete process.env.SMTP_HOST;
@@ -360,6 +371,12 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
// User settings with German language
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'de')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -390,12 +407,178 @@ describe("Planner Routes", () => {
// German date format should be used
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Supply Overview");
expect(mailCall.subject).toContain("Bestandsübersicht");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send push notification when shoutrrr is enabled", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via push" });
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
// Verify push message contains medication info
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
expect(title).toContain("Supply Overview");
expect(message).toContain("Aspirin");
});
it("should send both email and push when both enabled", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 5,
plannerUsage: 30,
blisterSize: 10,
blistersNeeded: 3,
fullBlisters: 0,
loosePills: 5,
enough: false,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via email and push" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
// Verify push message contains out of stock info
const [_url, _title, message] = mockSendShoutrrr.mock.calls[0];
expect(message).toContain("Aspirin");
expect(message).toContain("Empty");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send push with German translations", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 5,
plannerUsage: 30,
blisterSize: 10,
blistersNeeded: 3,
fullBlisters: 0,
loosePills: 5,
enough: false,
},
],
},
});
expect(response.statusCode).toBe(200);
// Check German translations in push
const [_url, title] = mockSendShoutrrr.mock.calls[0];
expect(title).toContain("Bestandsübersicht");
});
it("should handle push error gracefully", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toContain("Push:");
expect(response.json().error).toContain("Connection failed");
});
});
describe("POST /reminder/send-email", () => {
@@ -503,10 +686,10 @@ describe("Planner Routes", () => {
expect(response.statusCode).toBe(200);
// Check email contains EMPTY warning
// Check email contains empty warning
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Empty");
expect(mailCall.html).toContain("EMPTY");
expect(mailCall.html).toContain("empty");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
@@ -541,7 +724,7 @@ describe("Planner Routes", () => {
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Empty");
expect(mailCall.subject).toContain("Running Low");
expect(mailCall.subject).toContain("Critical");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
@@ -698,5 +881,103 @@ describe("Planner Routes", () => {
expect(response.json().error).toContain("Push:");
expect(response.json().error).toContain("Network error");
});
it("should differentiate critical and low stock in push notification", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true },
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
],
},
});
expect(response.statusCode).toBe(200);
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
// Title should contain both Critical and Low labels
expect(title).toContain("Critical");
expect(title).toContain("Low");
// Message should have separate sections
expect(message).toContain("Running critically low");
expect(message).toContain("Aspirin");
expect(message).toContain("Running low");
expect(message).toContain("Ibuprofen");
});
it("should differentiate critical and low stock in email", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true },
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
],
},
});
expect(response.statusCode).toBe(200);
const mailCall = mockSendMail.mock.calls[0][0];
// Subject should contain both Critical and Low
expect(mailCall.subject).toContain("Critical");
expect(mailCall.subject).toContain("Low");
// HTML should have separate alert boxes
expect(mailCall.html).toContain("critically low");
expect(mailCall.html).toContain("running low");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should label all meds as critical when isCritical not provided", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
expect(response.statusCode).toBe(200);
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
// Should be treated as critical (backwards compat)
expect(title).toContain("Critical");
expect(title).not.toContain("Low");
expect(message).toContain("Running critically low");
});
});
});
+66 -2
View File
@@ -51,6 +51,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
};
}
@@ -76,6 +77,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
shareStockStatus: Boolean(s.share_stock_status ?? 1),
};
});
@@ -102,6 +104,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
}>("/settings", async (request, reply) => {
const userId = 1;
@@ -150,8 +153,8 @@ async function registerSettingsRoutes(ctx: TestContext) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expiry_warning_days, language, stock_calculation_mode, share_stock_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
body.emailEnabled ? 1 : 0,
@@ -174,6 +177,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
],
});
} else {
@@ -200,6 +204,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiry_warning_days = ?,
language = ?,
stock_calculation_mode = ?,
share_stock_status = ?,
updated_at = strftime('%s','now')
WHERE user_id = ?`,
args: [
@@ -223,6 +228,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
userId,
],
});
@@ -542,6 +548,64 @@ describe("Settings API", () => {
});
});
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
describe("Share Stock Status", () => {
it("should default to true (show stock on shared links)", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(true);
});
it("should disable share stock status", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(false);
});
it("should re-enable share stock status", async () => {
// Disable first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
// Re-enable
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: true },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
+12 -5
View File
@@ -216,13 +216,14 @@ export interface UpdateUserSettingsOptions {
userId: number;
stockCalculationMode?: "automatic" | "manual";
lowStockDays?: number;
shareStockStatus?: boolean;
}
/**
* Create or update user settings
*/
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
// Check if settings exist
const existing = await client.execute({
@@ -232,13 +233,19 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting
if (existing.rows.length > 0) {
await client.execute({
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
args: [stockCalculationMode, lowStockDays, userId],
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
args:
shareStockStatus !== undefined
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
: [stockCalculationMode, lowStockDays, userId],
});
} else {
await client.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
args: [userId, stockCalculationMode, lowStockDays],
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
args:
shareStockStatus !== undefined
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
: [userId, stockCalculationMode, lowStockDays],
});
}
}
+45
View File
@@ -10,6 +10,7 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
@@ -141,6 +142,14 @@ async function registerShareRoutes(ctx: TestContext) {
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
// Get shareStockStatus setting
const shareStockResult = await client.execute({
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
args: [share.user_id],
});
const shareStockStatus =
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
return {
takenBy: share.taken_by,
sharedBy: share.owner_username,
@@ -149,6 +158,7 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -421,6 +431,41 @@ describe("Share Link API", () => {
expect(med.blisters).toHaveLength(1);
expect(med.blisters[0].usage).toBe(1);
expect(med.blisters[0].every).toBe(1);
// shareStockStatus should default to true
expect(data.shareStockStatus).toBe(true);
});
it("should respect shareStockStatus setting when disabled", async () => {
// Create medication
await createTestMedication(ctx.client, {
userId,
name: "TestMed",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
});
// Set shareStockStatus to false
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
// Create share token
const token = await createTestShareToken(ctx.client, {
userId,
takenBy: "Daniel",
scheduleDays: 30,
});
const response = await ctx.app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(false);
});
it("should return 404 for invalid token", async () => {
+4 -4
View File
@@ -69,8 +69,8 @@ describe("Translations Module", () => {
});
it("should replace multiple placeholders", () => {
const result = t("{count} {type} running low", { count: 3, type: "medications" });
expect(result).toBe("3 medications running low");
const result = t("{count} {type} running critically low", { count: 3, type: "medications" });
expect(result).toBe("3 medications running critically low");
});
it("should replace same placeholder multiple times", () => {
@@ -98,7 +98,7 @@ describe("Translations Module", () => {
// Stock reminder subject
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low");
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low");
// Intake reminder description
const description = t(translations.intakeReminder.description, { minutes: 30 });
@@ -113,7 +113,7 @@ describe("Translations Module", () => {
const translations = getTranslations("de");
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp");
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig");
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
expect(takenBy).toBe("für Daniel");