mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(notifications): show the mood reminder's real default in the matrix
The /notifications matrix read the constant default table, where the mood reminder is off, while delivery derives it from the reminder card. An account receiving the reminder saw every one of its channel switches off. The no-row default now lives in one resolver that delivery and the preferences read both use. GET /api/notifications/preferences publishes it as `eventDefaults`, the matrix renders a switch without a stored row from that map, and the reminder card refreshes the matrix when it flips.
This commit is contained in:
1 parent
421aacf457
commit
dc704b5e63
10 files changed
+487
-27
No files matched your search
+50
-3
@@ -82724,10 +82724,58 @@ components:
|
||||
items:
|
||||
type: string
|
||||
description: Every event category the server knows, in declaration order — the list a client renders rows for.
|
||||
eventDefaults:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
enum:
|
||||
- MEDICATION_REMINDER
|
||||
- MEASUREMENT_ANOMALY
|
||||
- COMPLIANCE_LOW
|
||||
- WITHINGS_SYNC_FAILED
|
||||
- SYSTEM_ALERT
|
||||
- PERSONAL_RECORD
|
||||
- MOOD_REMINDER
|
||||
- CYCLE_PERIOD_SOON
|
||||
- CYCLE_PERIOD_CONFIRM
|
||||
- CYCLE_FERTILE_SOON
|
||||
- COACH_NUDGE
|
||||
- MEDICATION_LOW_STOCK
|
||||
- MEASUREMENT_REMINDER
|
||||
- MEDICATION_INTAKE_SYNC
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
description: Notification category a per-channel preference can toggle.
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
required:
|
||||
- MEDICATION_REMINDER
|
||||
- MEASUREMENT_ANOMALY
|
||||
- COMPLIANCE_LOW
|
||||
- WITHINGS_SYNC_FAILED
|
||||
- SYSTEM_ALERT
|
||||
- PERSONAL_RECORD
|
||||
- MOOD_REMINDER
|
||||
- CYCLE_PERIOD_SOON
|
||||
- CYCLE_PERIOD_CONFIRM
|
||||
- CYCLE_FERTILE_SOON
|
||||
- COACH_NUDGE
|
||||
- MEDICATION_LOW_STOCK
|
||||
- MEASUREMENT_REMINDER
|
||||
- MEDICATION_INTAKE_SYNC
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
description: What each event does on a channel with no stored row, resolved for this account by the function delivery
|
||||
uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting
|
||||
(`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from
|
||||
a copy of the defaults. Since v1.43.11.
|
||||
required:
|
||||
- channels
|
||||
- preferences
|
||||
- eventTypes
|
||||
- eventDefaults
|
||||
additionalProperties: false
|
||||
description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary.
|
||||
NotificationChannelSummary:
|
||||
@@ -82797,9 +82845,8 @@ components:
|
||||
- eventType
|
||||
- enabled
|
||||
additionalProperties: false
|
||||
description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the
|
||||
server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood
|
||||
and cycle reminders). Absence is therefore not 'disabled'.
|
||||
description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows
|
||||
`eventDefaults` on the same response. Absence is therefore not 'disabled'.
|
||||
PutNotificationPreferenceResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -12,6 +12,7 @@ import { notificationPreferenceSchema } from "@/lib/validations/notifications";
|
||||
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { resolveEventDefaults } from "@/lib/notifications/event-defaults";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -92,6 +93,12 @@ export const GET = apiHandler(async () => {
|
||||
channels: channelData,
|
||||
preferences,
|
||||
eventTypes: EVENT_TYPES as unknown as string[],
|
||||
// What an event does on a channel with no stored row, resolved for this
|
||||
// account by the same function the dispatcher asks. A client renders a
|
||||
// switch without a row from this map, never from its own copy of the
|
||||
// defaults: the mood reminder's follows the reminder card, which a
|
||||
// constant table cannot know.
|
||||
eventDefaults: await resolveEventDefaults(user.id),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -126,4 +126,58 @@ describe("NotificationsPage", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("won’t receive notifications");
|
||||
});
|
||||
|
||||
it("a switch without a stored row shows the server's default for this account", () => {
|
||||
// The mood reminder is delivered when the reminder card is on, with no
|
||||
// per-channel row. The constant table says OFF for it, so a matrix that
|
||||
// read the table showed the switch off while the reminder kept arriving.
|
||||
// The page renders what the server resolved, row-less events included.
|
||||
queryState.data = {
|
||||
channels: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "TELEGRAM",
|
||||
label: "Telegram",
|
||||
enabled: true,
|
||||
globallyEnabled: true,
|
||||
},
|
||||
],
|
||||
preferences: [],
|
||||
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
|
||||
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
|
||||
};
|
||||
const html = render();
|
||||
const state = (label: string) =>
|
||||
new RegExp(
|
||||
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
|
||||
).exec(html);
|
||||
const mood = state("Mood reminder");
|
||||
const record = state("Personal record");
|
||||
expect(mood, "mood switch rendered").not.toBeNull();
|
||||
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
|
||||
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
|
||||
});
|
||||
|
||||
it("a stored row still wins over the default", () => {
|
||||
queryState.data = {
|
||||
channels: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "TELEGRAM",
|
||||
label: "Telegram",
|
||||
enabled: true,
|
||||
globallyEnabled: true,
|
||||
},
|
||||
],
|
||||
preferences: [
|
||||
{ channelId: "c1", eventType: "MOOD_REMINDER", enabled: false },
|
||||
],
|
||||
eventTypes: ["MOOD_REMINDER"],
|
||||
eventDefaults: { MOOD_REMINDER: true },
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toMatch(
|
||||
/aria-label="Mood reminder - Telegram"[^>]*data-state="unchecked"|data-state="unchecked"[^>]*aria-label="Mood reminder - Telegram"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,6 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import Link from "next/link";
|
||||
import { Bell, Settings, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
type EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDescriptionTranslationKey,
|
||||
eventTranslationKey,
|
||||
@@ -48,6 +44,11 @@ interface PreferencesData {
|
||||
channels: ChannelInfo[];
|
||||
preferences: Preference[];
|
||||
eventTypes: string[];
|
||||
/**
|
||||
* What each event does on a channel with no stored row, resolved by the
|
||||
* server for this account with the function the dispatcher uses.
|
||||
*/
|
||||
eventDefaults: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
@@ -116,10 +117,10 @@ export default function NotificationsPage() {
|
||||
(p) => p.channelId === channelId && p.eventType === eventType,
|
||||
);
|
||||
if (pref) return pref.enabled;
|
||||
// v1.4.25 — per-event default. Most events stay opt-out
|
||||
// (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF)
|
||||
// so a backfill doesn't fire hundreds of pushes on first sync.
|
||||
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
|
||||
// No row: the server's resolved default for this account. Read, never
|
||||
// recomputed here: the mood reminder's follows the reminder card, and a
|
||||
// copy of the constant table showed it off while it was being delivered.
|
||||
return data?.eventDefaults?.[eventType] === true;
|
||||
}
|
||||
|
||||
// Cross-links to the other two notification surfaces (reminder types +
|
||||
|
||||
@@ -112,9 +112,16 @@ export function MoodReminderCard({
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
}),
|
||||
// The /notifications matrix shows the reminder's channel switches
|
||||
// from the server's `eventDefaults`, which follow this card.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.notificationsPreferences(),
|
||||
}),
|
||||
]);
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* One answer to "what does an event do on a channel with no stored row".
|
||||
*
|
||||
* The dispatcher and the `/notifications` matrix used to answer it apart: the
|
||||
* dispatcher derived the mood reminder from the reminder card, the matrix read
|
||||
* the constant table, and an account receiving the reminder saw it switched
|
||||
* off. Both now read `event-defaults.ts`; this file pins what it answers and
|
||||
* that nothing on the client keeps its own copy of the table.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
|
||||
import { EVENT_DEFAULT_ENABLED, EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
eventDefaultFor,
|
||||
eventDefaultsFor,
|
||||
resolveEventDefault,
|
||||
resolveEventDefaults,
|
||||
} from "../event-defaults";
|
||||
|
||||
const findUnique = vi.mocked(prisma.user.findUnique);
|
||||
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
});
|
||||
|
||||
describe("eventDefaultsFor", () => {
|
||||
it("follows the reminder card for the mood reminder", () => {
|
||||
expect(eventDefaultsFor({ moodReminderEnabled: true }).MOOD_REMINDER).toBe(
|
||||
true,
|
||||
);
|
||||
expect(eventDefaultsFor({ moodReminderEnabled: false }).MOOD_REMINDER).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("answers every other event from the constant table", () => {
|
||||
const map = eventDefaultsFor({ moodReminderEnabled: true });
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
if (eventType === "MOOD_REMINDER") continue;
|
||||
expect(map[eventType], eventType).toBe(EVENT_DEFAULT_ENABLED[eventType]);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers every event the server knows", () => {
|
||||
expect(
|
||||
Object.keys(eventDefaultsFor({ moodReminderEnabled: false })).sort(),
|
||||
).toEqual([...EVENT_TYPES].sort());
|
||||
});
|
||||
|
||||
it("keeps the opt-out default for an event it does not know", () => {
|
||||
expect(
|
||||
eventDefaultFor("NOT_AN_EVENT", { moodReminderEnabled: false }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the account reads", () => {
|
||||
it("the map and the dispatcher's per-event answer agree", async () => {
|
||||
findUnique.mockResolvedValue({ moodReminderEnabled: true } as never);
|
||||
const map = await resolveEventDefaults("u1");
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
expect(await resolveEventDefault("u1", eventType), eventType).toBe(
|
||||
map[eventType],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the account only for an event whose default follows it", async () => {
|
||||
await resolveEventDefault("u1", "MEDICATION_REMINDER");
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
findUnique.mockResolvedValue({ moodReminderEnabled: false } as never);
|
||||
expect(await resolveEventDefault("u1", "MOOD_REMINDER")).toBe(false);
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no client keeps a copy of the table", () => {
|
||||
it("nothing under src/app or src/components imports EVENT_DEFAULT_ENABLED", () => {
|
||||
const root = join(process.cwd(), "src");
|
||||
const offenders: string[] = [];
|
||||
for (const dir of ["app", "components"]) {
|
||||
// The floor makes a walk that found nothing fail instead of passing.
|
||||
for (const rel of walkSourceFiles(join(root, dir), { floor: 300 })) {
|
||||
if (rel.includes("__tests__/")) continue;
|
||||
const text = readFileSync(join(root, dir, rel), "utf8");
|
||||
if (text.includes("EVENT_DEFAULT_ENABLED")) {
|
||||
offenders.push(`${dir}/${rel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The same matcher has to find the server modules that do hold the
|
||||
// table, or an empty offender list proves nothing about the matcher.
|
||||
const serverHolders = walkSourceFiles(join(root, "lib"), { floor: 300 })
|
||||
.filter((rel) => !rel.includes("__tests__/"))
|
||||
.filter((rel) =>
|
||||
readFileSync(join(root, "lib", rel), "utf8").includes(
|
||||
"EVENT_DEFAULT_ENABLED",
|
||||
),
|
||||
);
|
||||
expect(serverHolders.length).toBeGreaterThan(1);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,8 @@ import type {
|
||||
WebhookChannelConfig,
|
||||
EmailChannelConfig,
|
||||
ChannelType,
|
||||
EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
|
||||
import { resolveEventDefault } from "@/lib/notifications/event-defaults";
|
||||
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
|
||||
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
||||
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
||||
@@ -222,9 +221,7 @@ export async function dispatchNotification(
|
||||
// lock-screen on first launch. The user enables it explicitly
|
||||
// from /settings/notifications once they've seen the badge a few
|
||||
// times and decided they want push.
|
||||
let defaultEnabled =
|
||||
EVENT_DEFAULT_ENABLED[recipientPayload.eventType as EventType] ?? true;
|
||||
|
||||
//
|
||||
// v1.7.0 — MOOD_REMINDER single source of truth = the visible card.
|
||||
// The event used to default OFF, layered on top of the per-user
|
||||
// `moodReminderEnabled` flag, so a user who flipped the card still
|
||||
@@ -234,13 +231,14 @@ export async function dispatchNotification(
|
||||
// opt-out (a `NotificationPreference` row with `enabled = false`) is
|
||||
// still honoured below via `pref.enabled` — only the no-row default
|
||||
// is derived from the card.
|
||||
if (recipientPayload.eventType === "MOOD_REMINDER") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: delivery.recipientUserId },
|
||||
select: { moodReminderEnabled: true },
|
||||
});
|
||||
defaultEnabled = user?.moodReminderEnabled === true;
|
||||
}
|
||||
//
|
||||
// The resolution lives in `event-defaults.ts` because the /notifications
|
||||
// matrix has to show the same answer: it reads the map the preferences
|
||||
// route publishes from that module, never the constant table.
|
||||
const defaultEnabled = await resolveEventDefault(
|
||||
delivery.recipientUserId,
|
||||
recipientPayload.eventType,
|
||||
);
|
||||
// Operator kill switches for the three channels that carry one, read at
|
||||
// most once per dispatch and only when such a channel is actually in the
|
||||
// cascade — a cascade of APNs + webhook + email pays nothing for it.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* What an event does on a channel that carries no stored preference row.
|
||||
*
|
||||
* Most events have a constant answer (`EVENT_DEFAULT_ENABLED`). One does not:
|
||||
* the mood reminder has no switch of its own at the channel layer, it follows
|
||||
* the reminder card (`User.moodReminderEnabled`). The dispatcher has resolved
|
||||
* it that way since v1.7.0, while the `/notifications` matrix read the
|
||||
* constant table and showed the reminder as off on every channel of an account
|
||||
* that was receiving it.
|
||||
*
|
||||
* This module is the one answer both read. The dispatcher asks per event, the
|
||||
* preferences read publishes the whole map as `eventDefaults`, and a client
|
||||
* renders that map instead of keeping a copy of the table — so the switch a
|
||||
* person reads and the delivery they get cannot disagree again.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
EVENT_DEFAULT_ENABLED,
|
||||
EVENT_TYPES,
|
||||
type EventType,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
/** The account settings a default can follow. */
|
||||
export interface EventDefaultInputs {
|
||||
moodReminderEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The events whose default is read off the account rather than the constant
|
||||
* table. Kept as a map so the account read below happens only for them.
|
||||
*/
|
||||
const ACCOUNT_DERIVED: Partial<
|
||||
Record<EventType, (inputs: EventDefaultInputs) => boolean>
|
||||
> = {
|
||||
MOOD_REMINDER: (inputs) => inputs.moodReminderEnabled,
|
||||
};
|
||||
|
||||
function isEventType(value: string): value is EventType {
|
||||
return (EVENT_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** The no-row default for one event, given the account's settings. */
|
||||
export function eventDefaultFor(
|
||||
eventType: string,
|
||||
inputs: EventDefaultInputs,
|
||||
): boolean {
|
||||
if (!isEventType(eventType)) return true;
|
||||
const derived = ACCOUNT_DERIVED[eventType];
|
||||
return derived ? derived(inputs) : EVENT_DEFAULT_ENABLED[eventType];
|
||||
}
|
||||
|
||||
/** Every event's no-row default, given the account's settings. */
|
||||
export function eventDefaultsFor(
|
||||
inputs: EventDefaultInputs,
|
||||
): Record<EventType, boolean> {
|
||||
return Object.fromEntries(
|
||||
EVENT_TYPES.map((eventType) => [
|
||||
eventType,
|
||||
eventDefaultFor(eventType, inputs),
|
||||
]),
|
||||
) as Record<EventType, boolean>;
|
||||
}
|
||||
|
||||
async function readInputs(userId: string): Promise<EventDefaultInputs> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { moodReminderEnabled: true },
|
||||
});
|
||||
return { moodReminderEnabled: user?.moodReminderEnabled === true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The dispatcher's question. Reads the account only for an event whose
|
||||
* default follows it, so the common event types pay nothing.
|
||||
*/
|
||||
export async function resolveEventDefault(
|
||||
userId: string,
|
||||
eventType: string,
|
||||
): Promise<boolean> {
|
||||
if (!isEventType(eventType)) return true;
|
||||
const derived = ACCOUNT_DERIVED[eventType];
|
||||
return derived
|
||||
? derived(await readInputs(userId))
|
||||
: EVENT_DEFAULT_ENABLED[eventType];
|
||||
}
|
||||
|
||||
/** The preferences read's answer: the whole map for one account. */
|
||||
export async function resolveEventDefaults(
|
||||
userId: string,
|
||||
): Promise<Record<EventType, boolean>> {
|
||||
return eventDefaultsFor(await readInputs(userId));
|
||||
}
|
||||
@@ -66,7 +66,7 @@ const notificationPreference = z
|
||||
.meta({
|
||||
id: "NotificationPreference",
|
||||
description:
|
||||
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.",
|
||||
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows `eventDefaults` on the same response. Absence is therefore not 'disabled'.",
|
||||
});
|
||||
|
||||
const notificationPreferencesResponse = z
|
||||
@@ -78,6 +78,11 @@ const notificationPreferencesResponse = z
|
||||
.describe(
|
||||
"Every event category the server knows, in declaration order — the list a client renders rows for.",
|
||||
),
|
||||
eventDefaults: z
|
||||
.record(eventTypeEnum, z.boolean())
|
||||
.describe(
|
||||
"What each event does on a channel with no stored row, resolved for this account by the function delivery uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from a copy of the defaults. Since v1.43.11.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationPreferencesResponse",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The `/notifications` matrix and delivery give one answer for an event with
|
||||
* no stored preference row, against a real account row.
|
||||
*
|
||||
* The mood reminder is the event this exists for: it has no switch of its own
|
||||
* at the channel layer and follows the reminder card. Delivery read the card,
|
||||
* the matrix read a constant table, and an account receiving the reminder saw
|
||||
* it switched off on every channel. The preferences read now publishes
|
||||
* `eventDefaults` from the resolver delivery asks; this suite flips the real
|
||||
* column and checks the published switch and the actual send move together.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const ntfySendMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/ntfy", () => ({
|
||||
sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USERNAME = "event-defaults";
|
||||
|
||||
async function seedAccount(moodReminderEnabled: boolean): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: USERNAME,
|
||||
email: `${USERNAME}@example.test`,
|
||||
moodReminderEnabled,
|
||||
},
|
||||
});
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "NTFY",
|
||||
enabled: true,
|
||||
config: encrypt(
|
||||
JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }),
|
||||
),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function readDefaults(): Promise<Record<string, boolean>> {
|
||||
const { GET } = await import("@/app/api/notifications/preferences/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { preferences: unknown[]; eventDefaults: Record<string, boolean> };
|
||||
};
|
||||
// The case is about the no-row path; a stored row would decide instead.
|
||||
expect(body.data.preferences).toEqual([]);
|
||||
return body.data.eventDefaults;
|
||||
}
|
||||
|
||||
async function dispatchMood(userId: string) {
|
||||
const { dispatchNotification } =
|
||||
await import("@/lib/notifications/dispatcher");
|
||||
return dispatchNotification({
|
||||
userId,
|
||||
eventType: "MOOD_REMINDER",
|
||||
title: "How are you today?",
|
||||
message: "Log your mood",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
ntfySendMock.mockResolvedValue({ ok: true });
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("event defaults: the matrix shows what delivery does", () => {
|
||||
it("reminder card on: the matrix shows ON and the reminder is sent", async () => {
|
||||
const userId = await seedAccount(true);
|
||||
|
||||
expect((await readDefaults()).MOOD_REMINDER).toBe(true);
|
||||
|
||||
await dispatchMood(userId);
|
||||
expect(ntfySendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reminder card off: the matrix shows OFF and nothing is sent", async () => {
|
||||
const userId = await seedAccount(false);
|
||||
|
||||
expect((await readDefaults()).MOOD_REMINDER).toBe(false);
|
||||
|
||||
await dispatchMood(userId);
|
||||
expect(ntfySendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes a default for every event the server knows", async () => {
|
||||
await seedAccount(false);
|
||||
const { EVENT_TYPES } = await import("@/lib/notifications/types");
|
||||
expect(Object.keys(await readDefaults()).sort()).toEqual(
|
||||
[...EVENT_TYPES].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user