mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #706 from MBombeck/work/dw-c
Tell the owner when somebody else marks a dose
This commit is contained in:
14 files changed
+859
-13
No files matched your search
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
|
||||
"eventDailyBriefing": "Täglicher Überblick",
|
||||
"eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.",
|
||||
"eventSecurityAlert": "Neue Anmeldung",
|
||||
"eventSecurityAlertDesc": "Wird gesendet, wenn dein Konto auf einem unbekannten Gerät oder von einem unbekannten Ort verwendet wird.",
|
||||
"eventSharedRecordIntake": "Jemand mit Zugriff markiert eine Dosis",
|
||||
"eventSharedRecordIntakeDesc": "Wird nur gesendet, wenn eine andere Person es tut, nie bei deinen eigenen Einträgen.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
|
||||
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
|
||||
"unknownLocation": "unbekannter Ort"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} hat eine Dosis markiert",
|
||||
"bodyTaken": "{medication}: von {name} als eingenommen markiert.",
|
||||
"bodySkipped": "{medication}: von {name} als ausgelassen markiert."
|
||||
},
|
||||
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.",
|
||||
"someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.",
|
||||
"reminderSettingsLink": "Erinnerungseinstellungen",
|
||||
|
||||
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
|
||||
"eventDailyBriefing": "Daily briefing",
|
||||
"eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.",
|
||||
"eventSecurityAlert": "New sign-in",
|
||||
"eventSecurityAlertDesc": "Sent when your account is used on a device or from a place we have not seen before.",
|
||||
"eventSharedRecordIntake": "Someone you shared your record with marks a dose",
|
||||
"eventSharedRecordIntakeDesc": "Only sent when another person does it, never for your own entries.",
|
||||
"security": {
|
||||
"newDeviceTitle": "New sign-in to your account",
|
||||
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
|
||||
"unknownLocation": "unknown location"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} marked a dose",
|
||||
"bodyTaken": "{medication}: marked as taken by {name}.",
|
||||
"bodySkipped": "{medication}: marked as skipped by {name}."
|
||||
},
|
||||
"loginRequired": "Please sign in to manage notifications.",
|
||||
"someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.",
|
||||
"reminderSettingsLink": "Reminder settings",
|
||||
|
||||
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
|
||||
"eventDailyBriefing": "Resumen diario",
|
||||
"eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.",
|
||||
"eventSecurityAlert": "Nuevo inicio de sesión",
|
||||
"eventSecurityAlertDesc": "Se envía cuando se usa tu cuenta en un dispositivo o desde un lugar desconocidos.",
|
||||
"eventSharedRecordIntake": "Alguien con acceso marca una dosis",
|
||||
"eventSharedRecordIntakeDesc": "Solo se envía cuando lo hace otra persona, nunca con tus propias entradas.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
|
||||
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
|
||||
"unknownLocation": "ubicación desconocida"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} ha marcado una dosis",
|
||||
"bodyTaken": "{medication}: marcada como tomada por {name}.",
|
||||
"bodySkipped": "{medication}: marcada como omitida por {name}."
|
||||
},
|
||||
"loginRequired": "Inicia sesión para gestionar las notificaciones.",
|
||||
"someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.",
|
||||
"reminderSettingsLink": "Ajustes de recordatorios",
|
||||
|
||||
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
|
||||
"eventDailyBriefing": "Résumé quotidien",
|
||||
"eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.",
|
||||
"eventSecurityAlert": "Nouvelle connexion",
|
||||
"eventSecurityAlertDesc": "Envoyé quand votre compte est utilisé sur un appareil ou depuis un lieu inconnus.",
|
||||
"eventSharedRecordIntake": "Une personne avec accès marque une dose",
|
||||
"eventSharedRecordIntakeDesc": "Envoyé uniquement quand une autre personne le fait, jamais pour vos propres saisies.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nouvelle connexion à votre compte",
|
||||
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
|
||||
"unknownLocation": "lieu inconnu"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} a marqué une dose",
|
||||
"bodyTaken": "{medication} : marquée comme prise par {name}.",
|
||||
"bodySkipped": "{medication} : marquée comme ignorée par {name}."
|
||||
},
|
||||
"loginRequired": "Connecte-toi pour gérer les notifications.",
|
||||
"someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.",
|
||||
"reminderSettingsLink": "Réglages des rappels",
|
||||
|
||||
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
|
||||
"eventDailyBriefing": "Riepilogo quotidiano",
|
||||
"eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.",
|
||||
"eventSecurityAlert": "Nuovo accesso",
|
||||
"eventSecurityAlertDesc": "Inviato quando il tuo account viene usato su un dispositivo o da un luogo sconosciuti.",
|
||||
"eventSharedRecordIntake": "Una persona con accesso segna una dose",
|
||||
"eventSharedRecordIntakeDesc": "Inviato solo quando lo fa un'altra persona, mai per le tue voci.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuovo accesso al tuo account",
|
||||
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
|
||||
"unknownLocation": "posizione sconosciuta"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} ha segnato una dose",
|
||||
"bodyTaken": "{medication}: segnata come assunta da {name}.",
|
||||
"bodySkipped": "{medication}: segnata come saltata da {name}."
|
||||
},
|
||||
"loginRequired": "Accedi per gestire le notifiche.",
|
||||
"someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.",
|
||||
"reminderSettingsLink": "Impostazioni promemoria",
|
||||
|
||||
@@ -7089,11 +7089,20 @@
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
|
||||
"eventDailyBriefing": "Codzienne podsumowanie",
|
||||
"eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.",
|
||||
"eventSecurityAlert": "Nowe logowanie",
|
||||
"eventSecurityAlertDesc": "Wysyłane, gdy Twoje konto zostanie użyte na nieznanym urządzeniu lub z nieznanego miejsca.",
|
||||
"eventSharedRecordIntake": "Ktoś z dostępem oznacza dawkę",
|
||||
"eventSharedRecordIntakeDesc": "Wysyłane tylko wtedy, gdy robi to inna osoba, nigdy przy Twoich własnych wpisach.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nowe logowanie do Twojego konta",
|
||||
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
|
||||
"unknownLocation": "nieznana lokalizacja"
|
||||
},
|
||||
"sharedRecordIntake": {
|
||||
"title": "{name} oznaczył(a) dawkę",
|
||||
"bodyTaken": "{medication}: oznaczona jako przyjęta przez {name}.",
|
||||
"bodySkipped": "{medication}: oznaczona jako pominięta przez {name}."
|
||||
},
|
||||
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.",
|
||||
"someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.",
|
||||
"reminderSettingsLink": "Ustawienia przypomnień",
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
} from "@/lib/notifications/event-labels";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -38,17 +42,6 @@ interface PreferencesData {
|
||||
eventTypes: string[];
|
||||
}
|
||||
|
||||
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
|
||||
function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
"notifications.event" +
|
||||
eventType
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -265,7 +258,7 @@ export default function NotificationsPage() {
|
||||
<tbody>
|
||||
{eventTypes.map((eventType, idx) => {
|
||||
const nameKey = eventTranslationKey(eventType);
|
||||
const descKey = nameKey + "Desc";
|
||||
const descKey = eventDescriptionTranslationKey(eventType);
|
||||
return (
|
||||
<tr
|
||||
key={eventType}
|
||||
@@ -311,7 +304,7 @@ export default function NotificationsPage() {
|
||||
<div className="space-y-4 md:hidden">
|
||||
{eventTypes.map((eventType) => {
|
||||
const nameKey = eventTranslationKey(eventType);
|
||||
const descKey = nameKey + "Desc";
|
||||
const descKey = eventDescriptionTranslationKey(eventType);
|
||||
return (
|
||||
<div
|
||||
key={eventType}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* v1.36.x — "somebody else marked your dose" helper.
|
||||
*
|
||||
* What is pinned here, and why each one is a property rather than an
|
||||
* observation:
|
||||
*
|
||||
* 1. The notification is addressed to the OWNER and names the acting person
|
||||
* and the medication. The delegate is not a recipient anywhere in this
|
||||
* file, because there is no delegate-directed push to assert.
|
||||
* 2. The self refusal lives in the helper, so a future call site that forgets
|
||||
* to compare ids still cannot notify somebody about their own dose.
|
||||
* 3. A snooze is not a marking and produces nothing.
|
||||
* 4. The medication is read scoped to the owner — the name that reaches a
|
||||
* lock screen comes from the recipient's own rows.
|
||||
* 5. Nothing here throws: a failed notification never fails the write that
|
||||
* produced it.
|
||||
*
|
||||
* Mutation check (run before this file was committed): deleting the
|
||||
* `actorId === ownerId` guard turns "fires nothing when the owner marks their
|
||||
* own dose" red with `expected "spy" to not be called at all, but actually
|
||||
* been called 1 times`. Deleting the `messageKey === null` return turns the
|
||||
* snooze case red the same way.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const dispatchLocalisedMock = vi.fn();
|
||||
const addWarningMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: (...args: unknown[]) =>
|
||||
dispatchLocalisedMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }),
|
||||
}));
|
||||
|
||||
const userFindUnique = vi.fn();
|
||||
const medicationFindFirst = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
|
||||
medication: { findFirst: (...a: unknown[]) => medicationFindFirst(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
delegatedIntakeMessageKey,
|
||||
notifyDelegatedIntake,
|
||||
} from "@/lib/notifications/delegated-intake";
|
||||
|
||||
const OWNER = "owner-account";
|
||||
const DELEGATE = "delegate-account";
|
||||
const MEDICATION = "med-1";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userFindUnique.mockResolvedValue({
|
||||
username: "wren",
|
||||
displayName: "Wren",
|
||||
});
|
||||
medicationFindFirst.mockResolvedValue({ name: "Ramipril" });
|
||||
dispatchLocalisedMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("delegatedIntakeMessageKey", () => {
|
||||
it("maps a marking to its own body and a snooze to nothing", () => {
|
||||
expect(delegatedIntakeMessageKey("taken")).toBe(
|
||||
"notifications.sharedRecordIntake.bodyTaken",
|
||||
);
|
||||
expect(delegatedIntakeMessageKey("skipped")).toBe(
|
||||
"notifications.sharedRecordIntake.bodySkipped",
|
||||
);
|
||||
expect(delegatedIntakeMessageKey("snoozed")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyDelegatedIntake", () => {
|
||||
it("notifies the owner, naming the acting person and the medication", async () => {
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchLocalisedMock).toHaveBeenCalledWith({
|
||||
userId: OWNER,
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
titleKey: "notifications.sharedRecordIntake.title",
|
||||
messageKey: "notifications.sharedRecordIntake.bodyTaken",
|
||||
params: { name: "Wren", medication: "Ramipril" },
|
||||
metadata: { medicationId: MEDICATION },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the skipped body for a skipped dose", async () => {
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "skipped",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock.mock.calls[0][0]).toMatchObject({
|
||||
messageKey: "notifications.sharedRecordIntake.bodySkipped",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the username when the acting account set no display name", async () => {
|
||||
userFindUnique.mockResolvedValue({ username: "wren", displayName: null });
|
||||
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock.mock.calls[0][0].params).toMatchObject({
|
||||
name: "wren",
|
||||
});
|
||||
});
|
||||
|
||||
it("fires nothing when the owner marks their own dose", async () => {
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: OWNER,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
|
||||
// Not even a lookup: the refusal is the first thing the helper does.
|
||||
expect(userFindUnique).not.toHaveBeenCalled();
|
||||
expect(medicationFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires nothing for a snooze", async () => {
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "snoozed",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the medication scoped to the owner", async () => {
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(medicationFindFirst).toHaveBeenCalledWith({
|
||||
where: { id: MEDICATION, userId: OWNER },
|
||||
select: { name: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("stays silent, with a warning, when the medication is not the owner's", async () => {
|
||||
medicationFindFirst.mockResolvedValue(null);
|
||||
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("medication=missing"),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows a dispatch failure so the write that produced it survives", async () => {
|
||||
dispatchLocalisedMock.mockRejectedValue(new Error("telegram is down"));
|
||||
|
||||
await expect(
|
||||
notifyDelegatedIntake({
|
||||
ownerId: OWNER,
|
||||
actorId: DELEGATE,
|
||||
medicationId: MEDICATION,
|
||||
status: "taken",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(addWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("telegram is down"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,58 @@ describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", ()
|
||||
|
||||
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// v1.36.x — the delegated-dose event goes the other way from
|
||||
// PERSONAL_RECORD: ON without a row, because the owner invited the person
|
||||
// whose action produces it. Both positions of the preference are asserted,
|
||||
// so "default ON" cannot be true only because the dispatcher ignores the
|
||||
// event entirely.
|
||||
it("SHARED_RECORD_INTAKE defaults ON when no preference row exists", async () => {
|
||||
const channel = makeChannel({
|
||||
type: "NTFY",
|
||||
config: JSON.stringify({
|
||||
serverUrl: "https://ntfy.example",
|
||||
topic: "t",
|
||||
}),
|
||||
preferences: [],
|
||||
});
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
|
||||
channel,
|
||||
] as never);
|
||||
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
userId: "u-1",
|
||||
title: "Wren marked a dose",
|
||||
message: "Ramipril: marked as taken by Wren.",
|
||||
});
|
||||
|
||||
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("SHARED_RECORD_INTAKE is suppressed by an explicit per-channel opt-out", async () => {
|
||||
const channel = makeChannel({
|
||||
type: "NTFY",
|
||||
config: JSON.stringify({
|
||||
serverUrl: "https://ntfy.example",
|
||||
topic: "t",
|
||||
}),
|
||||
preferences: [{ eventType: "SHARED_RECORD_INTAKE", enabled: false }],
|
||||
});
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
|
||||
channel,
|
||||
] as never);
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
userId: "u-1",
|
||||
title: "Wren marked a dose",
|
||||
message: "Ramipril: marked as taken by Wren.",
|
||||
});
|
||||
|
||||
expect(sendViaNtfyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchNotification — MOOD_REMINDER single source of truth (v1.7.0)", () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Every event type the /notifications matrix renders has words to render.
|
||||
*
|
||||
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
|
||||
* and description key from the event name, so a new event type silently gets a
|
||||
* row whether or not anybody wrote the two strings for it. That is not a
|
||||
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
|
||||
* `notifications.eventSecurityAlert` in the matrix until v1.36.x, because
|
||||
* nothing anywhere asserted the pairing — the forward i18n coverage test only
|
||||
* sees literal `t("…")` call sites, and this key is assembled from a runtime
|
||||
* value.
|
||||
*
|
||||
* So this test walks the event list the page walks, computes the keys the page
|
||||
* computes, and asserts both resolve in every locale bundle.
|
||||
*
|
||||
* Mutation check: remove `notifications.eventSharedRecordIntakeDesc` from
|
||||
* `messages/pl.json` and this file reports
|
||||
* `pl is missing notifications.eventSharedRecordIntakeDesc`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
} from "@/lib/notifications/event-labels";
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../../../messages");
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolve(root: Record<string, unknown>, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
root,
|
||||
);
|
||||
}
|
||||
|
||||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
|
||||
describe("notification event labels", () => {
|
||||
it("derives the key the matrix renders under", () => {
|
||||
expect(eventTranslationKey("MEDICATION_REMINDER")).toBe(
|
||||
"notifications.eventMedicationReminder",
|
||||
);
|
||||
expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe(
|
||||
"notifications.eventSharedRecordIntakeDesc",
|
||||
);
|
||||
});
|
||||
|
||||
it("has a label and a description for every event type, in every locale", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
expect(EVENT_TYPES.length).toBeGreaterThan(10);
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
for (const key of [
|
||||
eventTranslationKey(eventType),
|
||||
eventDescriptionTranslationKey(eventType),
|
||||
]) {
|
||||
const value = resolve(root, key);
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
missing.push(`${locale} is missing ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the delegated-dose push copy", () => {
|
||||
it("interpolates the same names in every locale", () => {
|
||||
const keys = [
|
||||
"notifications.sharedRecordIntake.title",
|
||||
"notifications.sharedRecordIntake.bodyTaken",
|
||||
"notifications.sharedRecordIntake.bodySkipped",
|
||||
];
|
||||
const placeholders: Record<string, string[]> = {
|
||||
"notifications.sharedRecordIntake.title": ["{name}"],
|
||||
"notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"],
|
||||
"notifications.sharedRecordIntake.bodySkipped": [
|
||||
"{name}",
|
||||
"{medication}",
|
||||
],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const root = bundle(locale);
|
||||
for (const key of keys) {
|
||||
const value = resolve(root, key);
|
||||
expect(typeof value, `${locale} ${key}`).toBe("string");
|
||||
for (const token of placeholders[key]) {
|
||||
expect(value as string, `${locale} ${key}`).toContain(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.36.x — "somebody else marked your dose".
|
||||
*
|
||||
* The one notification a delegated write produces. Everything else a delegate
|
||||
* adds to a shared record (a reading, a lab result, an allergy) is bookkeeping
|
||||
* and lives in the owner's activity view; a dose is a statement about the
|
||||
* owner's own body, and the owner is the person best placed to decide whether
|
||||
* they want to hear about it. So this rides the ordinary alert cascade with an
|
||||
* ordinary per-channel preference row, and nothing about it is special-cased
|
||||
* inside the dispatcher.
|
||||
*
|
||||
* Two properties are structural rather than observed:
|
||||
*
|
||||
* 1. **It refuses to fire on self.** The owner never gets a push about their
|
||||
* own dose. The refusal lives HERE, not at the call sites, so a future
|
||||
* route that calls this helper cannot forget it.
|
||||
* 2. **It addresses the owner and only the owner.** The delegate gets their
|
||||
* feedback from the response they are already awaiting, rendered in their
|
||||
* own session. There is no delegate-directed push and this file is not the
|
||||
* place to grow one: the dispatcher takes a `userId`, and under record
|
||||
* substitution that id is the owner by design.
|
||||
*
|
||||
* The medication is named in the push body, the same verbosity class as
|
||||
* MEDICATION_REMINDER, which has named medications on the lock screen since
|
||||
* v1.4.40. No dose figures ride along.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
|
||||
/**
|
||||
* The dose states the intake routes write. `"snoozed"` is deliberately in the
|
||||
* union: both intake routes can produce it, and the helper answering "that one
|
||||
* is not a marking" in one place beats every call site remembering to.
|
||||
*/
|
||||
export type DelegatedIntakeStatus = "taken" | "skipped" | "snoozed";
|
||||
|
||||
/**
|
||||
* Which body the push carries for a dose state, or `null` when the state is
|
||||
* not a marking at all.
|
||||
*
|
||||
* Pure, exported, and tested on its own: a snooze moves a dose, it does not
|
||||
* resolve it, and the web-push dose-clear draws the same line (`intake/route
|
||||
* .ts` clears the pending reminder for taken / skipped only). An owner woken
|
||||
* by "your carer postponed a dose by ten minutes" would be the noise this
|
||||
* whole event type is trying not to be.
|
||||
*/
|
||||
export function delegatedIntakeMessageKey(
|
||||
status: DelegatedIntakeStatus,
|
||||
): string | null {
|
||||
switch (status) {
|
||||
case "taken":
|
||||
return "notifications.sharedRecordIntake.bodyTaken";
|
||||
case "skipped":
|
||||
return "notifications.sharedRecordIntake.bodySkipped";
|
||||
case "snoozed":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotifyDelegatedIntakeInput {
|
||||
/** The account the dose belongs to — the recipient of the notification. */
|
||||
ownerId: string;
|
||||
/** The account that actually marked it. Equal to `ownerId` on a self-write. */
|
||||
actorId: string;
|
||||
/** The medication whose dose was marked. Resolved for its name, owner-scoped. */
|
||||
medicationId: string;
|
||||
/** What the dose was marked as. */
|
||||
status: DelegatedIntakeStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the owner that somebody else marked one of their doses.
|
||||
*
|
||||
* Never throws: a notification that fails is not a reason to fail the write
|
||||
* that produced it. Await it — the call sites reach this only on a delegated
|
||||
* write, so the added latency lands on the caregiver's request and never on
|
||||
* the owner's own hot path, and awaiting is what makes "one row in
|
||||
* `push_attempts`" a thing a test can assert rather than a thing it can race.
|
||||
*/
|
||||
export async function notifyDelegatedIntake(
|
||||
input: NotifyDelegatedIntakeInput,
|
||||
): Promise<void> {
|
||||
const { ownerId, actorId, medicationId, status } = input;
|
||||
|
||||
// The self refusal. First, cheap, and unconditional.
|
||||
if (actorId === ownerId) return;
|
||||
|
||||
const messageKey = delegatedIntakeMessageKey(status);
|
||||
if (messageKey === null) return;
|
||||
|
||||
try {
|
||||
const [actor, medication] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { username: true, displayName: true },
|
||||
}),
|
||||
// Scoped to the owner: the name in this body goes onto the owner's lock
|
||||
// screen, so it is read from the owner's own row set or not at all.
|
||||
prisma.medication.findFirst({
|
||||
where: { id: medicationId, userId: ownerId },
|
||||
select: { name: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Both halves of the sentence or none of it. An acting account that no
|
||||
// longer resolves is nobody we can name, and a medication that does not
|
||||
// resolve under the OWNER is not one whose name belongs on their lock
|
||||
// screen. Neither is reachable from the intake routes, which have both
|
||||
// rows in hand before they call; a warning to ops beats a push that says
|
||||
// "somebody marked something".
|
||||
if (!actor || !medication) {
|
||||
getEvent()?.addWarning(
|
||||
`notifyDelegatedIntake — nothing to name (actor=${actor ? "ok" : "missing"} medication=${medication ? "ok" : "missing"}), no notification sent`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatchLocalisedNotification({
|
||||
userId: ownerId,
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
titleKey: "notifications.sharedRecordIntake.title",
|
||||
messageKey,
|
||||
params: {
|
||||
name: accountLabel(actor),
|
||||
medication: medication.name,
|
||||
},
|
||||
metadata: { medicationId },
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`notifyDelegatedIntake failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The i18n key an event type is rendered under.
|
||||
*
|
||||
* The /notifications matrix builds its rows from the event list the server
|
||||
* publishes, so the label for a new event type is never written at a call site
|
||||
* — it is derived here. Extracted from the page so the derivation can be
|
||||
* pinned by a test without rendering anything, and so the guard that asserts
|
||||
* every event type actually HAS its two strings (label + description, six
|
||||
* locales) computes the key exactly the way the page does rather than a way
|
||||
* that agrees with it today.
|
||||
*
|
||||
* `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the
|
||||
* description key is that plus `Desc`.
|
||||
*/
|
||||
export function eventTranslationKey(eventType: string): string {
|
||||
return (
|
||||
"notifications.event" +
|
||||
eventType
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/** The description key that sits under the label in the matrix row. */
|
||||
export function eventDescriptionTranslationKey(eventType: string): string {
|
||||
return `${eventTranslationKey(eventType)}Desc`;
|
||||
}
|
||||
@@ -112,6 +112,16 @@ export const EVENT_TYPES = [
|
||||
// morning window, so a concerning reading always stays on
|
||||
// `MEASUREMENT_ANOMALY` and this never bypasses Focus.
|
||||
"DAILY_BRIEFING",
|
||||
// v1.36.x — somebody with a write grant on this record marked one of the
|
||||
// owner's doses. The only notification a delegated write produces: every
|
||||
// other shared-record write is bookkeeping and stays in the owner's activity
|
||||
// view, while "somebody said I took my tablets" is a fact about the owner's
|
||||
// own body that they should not have to go looking for. Addressed to the
|
||||
// OWNER — the delegate learns what they did from the response in their own
|
||||
// session, not from a push (there is no channel that could carry it: the
|
||||
// dispatcher addresses a userId, and under record substitution that userId
|
||||
// is the owner by design). Default ON, see EVENT_DEFAULT_ENABLED.
|
||||
"SHARED_RECORD_INTAKE",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -179,6 +189,14 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
// channel that has no explicit enabled row, so a fresh account never
|
||||
// receives a morning push it did not ask for.
|
||||
DAILY_BRIEFING: false,
|
||||
// v1.36.x — ON by default, deliberately against the PERSONAL_RECORD opt-in
|
||||
// precedent. The event cannot exist unless the owner invited a second person
|
||||
// and gave them write access, so there is no backfill class of volume to
|
||||
// protect against; and an owner who has to discover a setting before they
|
||||
// learn that somebody else marks their doses was never told at all. The row
|
||||
// sits in the /notifications matrix from day one, so an owner who finds it
|
||||
// noisy silences it per channel with one switch.
|
||||
SHARED_RECORD_INTAKE: true,
|
||||
};
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* v1.36.x — the delegated-dose notification, end to end against Postgres.
|
||||
*
|
||||
* The helper is exercised through the real dispatcher, the real preference
|
||||
* lookup and the real APNs sender (with the vendor client stubbed, the same
|
||||
* shape `apns-dispatch.test.ts` uses), so what is asserted is the row the
|
||||
* ledger actually holds rather than the call the helper meant to make.
|
||||
*
|
||||
* The three facts:
|
||||
*
|
||||
* 1. The owner gets exactly ONE `push_attempts` row for the event, and the
|
||||
* delegate gets none. There is no delegate-directed push; the caregiver
|
||||
* learns what they did from the response in their own session.
|
||||
* 2. An owner marking their own dose produces no send and no row at all.
|
||||
* 3. The owner's per-channel opt-out row suppresses it, which is what makes
|
||||
* "default ON" a defaulted decision rather than an unconditional one.
|
||||
*
|
||||
* The ledger write is deliberately fire-and-forget in `recordPushAttempt`, so
|
||||
* the assertions poll for the expected row instead of reading once and hoping.
|
||||
* The poll is bounded and then asserted exactly: a helper that stops
|
||||
* dispatching times out and fails on the count, it does not pass on an empty
|
||||
* table.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const apnsSendMock = vi.fn();
|
||||
|
||||
vi.mock("@parse/node-apn", () => {
|
||||
class Provider {
|
||||
constructor(_opts: unknown) {
|
||||
void _opts;
|
||||
}
|
||||
send(...args: unknown[]) {
|
||||
return apnsSendMock(...args);
|
||||
}
|
||||
shutdown() {}
|
||||
}
|
||||
class Notification {
|
||||
public topic = "";
|
||||
public alert: unknown;
|
||||
public sound: string | undefined;
|
||||
public badge: number | undefined;
|
||||
public threadId: string | undefined;
|
||||
public collapseId: string | undefined;
|
||||
public payload: unknown;
|
||||
}
|
||||
return { default: { Provider, Notification }, Provider, Notification };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const OWNER_ID = "user-delegated-intake-owner";
|
||||
const DELEGATE_ID = "user-delegated-intake-delegate";
|
||||
|
||||
// Test-only EC P-256 key — `loadApnsConfig` parses it before the sender will
|
||||
// talk to the stubbed provider. Same fixture shape as `apns-dispatch.test.ts`.
|
||||
const TEST_EC_PEM_LINES = [
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
|
||||
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
|
||||
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
|
||||
"bw81BG89",
|
||||
"-----END PRIVATE KEY-----",
|
||||
];
|
||||
|
||||
const APNS_ENV = {
|
||||
APNS_KEY_ID: "ABCDE12345",
|
||||
APNS_TEAM_ID: "TEAM123456",
|
||||
APNS_BUNDLE_ID: "test.healthlog.ios",
|
||||
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
|
||||
};
|
||||
|
||||
async function seedMedication(): Promise<string> {
|
||||
const med = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
async function seedApnsChannel(): Promise<void> {
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
await getPrismaClient().notificationChannel.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "APNS",
|
||||
enabled: true,
|
||||
config: encrypt("{}"),
|
||||
},
|
||||
});
|
||||
await getPrismaClient().device.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
platform: "ios",
|
||||
token: "device-owner",
|
||||
bundleId: APNS_ENV.APNS_BUNDLE_ID,
|
||||
apnsToken: "owner-apns-token",
|
||||
apnsEnvironment: "sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll the ledger until it holds `expected` rows for the user, or give up. */
|
||||
async function pushAttemptsFor(
|
||||
userId: string,
|
||||
expected: number,
|
||||
): Promise<{ channel: string; eventType: string; result: string }[]> {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const rows = await getPrismaClient().pushAttempt.findMany({
|
||||
where: { userId },
|
||||
select: { channel: true, eventType: true, result: true },
|
||||
});
|
||||
if (rows.length >= expected) return rows;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return getPrismaClient().pushAttempt.findMany({
|
||||
where: { userId },
|
||||
select: { channel: true, eventType: true, result: true },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
for (const [k, v] of Object.entries(APNS_ENV)) process.env[k] = v;
|
||||
delete process.env.APNS_PRODUCTION;
|
||||
const apns = await import("@/lib/notifications/senders/apns");
|
||||
apns.resetApnsForTesting();
|
||||
const { __resetDispatchLocaleCacheForTests } =
|
||||
await import("@/lib/notifications/dispatch-localised");
|
||||
__resetDispatchLocaleCacheForTests();
|
||||
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: OWNER_ID,
|
||||
username: "owner-delegated-intake",
|
||||
email: "owner-delegated-intake@example.test",
|
||||
locale: "en",
|
||||
},
|
||||
{
|
||||
id: DELEGATE_ID,
|
||||
username: "wren",
|
||||
displayName: "Wren",
|
||||
email: "wren-delegated-intake@example.test",
|
||||
locale: "de",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
apnsSendMock.mockResolvedValue({
|
||||
sent: [{ device: "owner-apns-token" }],
|
||||
failed: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated intake notification", () => {
|
||||
it("notifies the owner once and the acting delegate not at all", async () => {
|
||||
await seedApnsChannel();
|
||||
const medicationId = await seedMedication();
|
||||
|
||||
const { notifyDelegatedIntake } =
|
||||
await import("@/lib/notifications/delegated-intake");
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER_ID,
|
||||
actorId: DELEGATE_ID,
|
||||
medicationId,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
// The ledger first, deliberately: a dispatch that never landed and a
|
||||
// dispatch nobody recorded look identical from the mock's side.
|
||||
const ownerRows = await pushAttemptsFor(OWNER_ID, 1);
|
||||
expect(ownerRows).toEqual([
|
||||
{ channel: "APNS", eventType: "SHARED_RECORD_INTAKE", result: "ok" },
|
||||
]);
|
||||
|
||||
const delegateRows = await getPrismaClient().pushAttempt.findMany({
|
||||
where: { userId: DELEGATE_ID },
|
||||
});
|
||||
expect(delegateRows).toEqual([]);
|
||||
|
||||
expect(apnsSendMock).toHaveBeenCalledTimes(1);
|
||||
const note = apnsSendMock.mock.calls[0][0];
|
||||
// The owner's locale, not the delegate's, and the medication named the
|
||||
// way MEDICATION_REMINDER names one.
|
||||
expect(note.alert.title).toBe("Wren marked a dose");
|
||||
expect(note.alert.body).toBe("Ramipril: marked as taken by Wren.");
|
||||
expect(note.payload).toMatchObject({
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
medicationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends nothing when the owner marks their own dose", async () => {
|
||||
await seedApnsChannel();
|
||||
const medicationId = await seedMedication();
|
||||
|
||||
const { notifyDelegatedIntake } =
|
||||
await import("@/lib/notifications/delegated-intake");
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER_ID,
|
||||
actorId: OWNER_ID,
|
||||
medicationId,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(apnsSendMock).not.toHaveBeenCalled();
|
||||
// Wait out the ledger's fire-and-forget window before asserting absence,
|
||||
// so "no row" means no row rather than "not yet".
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("honours the owner's per-channel opt-out", async () => {
|
||||
await seedApnsChannel();
|
||||
const medicationId = await seedMedication();
|
||||
const channel =
|
||||
await getPrismaClient().notificationChannel.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, type: "APNS" },
|
||||
});
|
||||
await getPrismaClient().notificationPreference.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
eventType: "SHARED_RECORD_INTAKE",
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { notifyDelegatedIntake } =
|
||||
await import("@/lib/notifications/delegated-intake");
|
||||
await notifyDelegatedIntake({
|
||||
ownerId: OWNER_ID,
|
||||
actorId: DELEGATE_ID,
|
||||
medicationId,
|
||||
status: "taken",
|
||||
});
|
||||
|
||||
expect(apnsSendMock).not.toHaveBeenCalled();
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user