test(integration): move notification channel and workout route tests onto Postgres

The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 05:32:22 +02:00
1 parent ff9ef0883b
commit 43b86e46da
16 files changed
+2868 -3247

No files matched your search

@@ -1,418 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: vi.fn().mockResolvedValue(true),
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: vi.fn(() => true),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT as putWebhook } from "../webhook/route";
import { PUT as putNtfy } from "../ntfy/route";
import { PUT as putEmail } from "../email/route";
import { PUT as putTelegram } from "../telegram/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { setTelegramWebhook } from "@/lib/telegram";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const routes = {
webhook: putWebhook,
ntfy: putNtfy,
email: putEmail,
telegram: putTelegram,
};
type PutRoute = (request: Request) => Promise<Response>;
function put(channel: keyof typeof routes, body: unknown) {
return (routes[channel] as PutRoute)(
new Request(`http://localhost/api/settings/${channel}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("notification channel enable-only routes", () => {
it.each(Object.keys(routes) as Array<keyof typeof routes>)(
"%s rejects unauthenticated toggle writes",
async (channel) => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await put(channel, { enabled: false });
expect(response.status).toBe(401);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
},
);
it("updates only the owned webhook enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
} as never);
const response = await put("webhook", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "WEBHOOK" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned ntfy enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned email enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"saved@example.com"}',
} as never);
const response = await put("email", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "EMAIL" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only Telegram enabled fields and leaves stored credentials untouched", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: true,
} as never);
const response = await put("telegram", { enabled: false });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { updated: true },
error: null,
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: false },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: false },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.deleteMany).not.toHaveBeenCalled();
});
it("registers Telegram on enable without rewriting stored credentials", async () => {
vi.stubEnv("APP_URL", "https://health.example");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
const response = await put("telegram", { enabled: true });
expect(response.status).toBe(200);
expect(setTelegramWebhook).toHaveBeenCalledWith(
"saved-token",
"https://health.example/api/telegram/webhook",
undefined,
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: true },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it.each([
[
"webhook",
{
url: "https://saved.example/hook",
headerName: "X-Test",
headerValue: "secret",
enabled: false,
},
],
[
"ntfy",
{
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "secret",
enabled: false,
},
],
["email", { recipient: "saved@example.com", enabled: false }],
] as const)(
"keeps the existing full %s save contract",
async (channel, body) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, body);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
},
);
it("keeps the existing full Telegram save contract", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
} as never);
const response = await put("telegram", {
botToken: "new-token",
chatId: "new-chat",
enabled: false,
});
expect(response.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
telegramEnabled: false,
telegramBotToken: "encrypted:new-token",
telegramChatId: "new-chat",
},
});
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it.each(["webhook", "ntfy", "email"] as const)(
"rejects enabling an unconfigured %s channel without creating configuration",
async (channel) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, { enabled: true });
expect(response.status).toBe(422);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
},
);
});
/**
* v1.32.1 — regression for iOS coordination issue #63: `PUT
* /api/settings/ntfy` reconstructed the entire stored config from the
* request body. `GET` never returns the write-only `authToken` (only
* `hasAuthToken: true/false`), so both native and web clients correctly
* OMIT — or round-trip an empty string through — the field on any
* unrelated edit. Rebuilding `config` without a preserve fallback
* silently dropped a working token on the next save.
*
* The fix mirrors the pre-existing `headerValue` preserve-on-empty
* contract in `src/app/api/settings/webhook/route.ts` (asserted here too,
* as a parity pin — that route was never broken, but any future drift
* between the two should fail the same suite). Covers exactly the four
* cases the report asks for.
*/
describe("PUT /api/settings/ntfy — write-only authToken preserve-on-omit contract", () => {
function upsertConfig() {
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as {
create: { config: string };
update: { config: string };
};
// The route always upserts — assert both arms agree, then decode one.
expect(call.update.config).toBe(call.create.config);
return JSON.parse(call.update.config.replace(/^encrypted:/, "")) as {
serverUrl: string;
topic: string;
authToken?: string;
};
}
it("an omitted authToken preserves the stored secret", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("an explicit empty-string authToken preserves the stored secret (matches current clients)", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("a non-empty authToken replaces the stored secret", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"old-secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "new-secret",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("new-secret");
});
it("creating without a token remains valid where anonymous ntfy is allowed", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "public-topic",
enabled: false,
});
expect(response.status).toBe(200);
const config = upsertConfig();
expect(config.authToken).toBeUndefined();
expect(config.topic).toBe("public-topic");
});
it("parity pin: the webhook headerValue preserve-on-empty contract this fix mirrors is intact", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
} as never);
const response = await put("webhook", {
url: "https://saved.example/hook",
headerName: "Authorization",
enabled: true,
});
expect(response.status).toBe(200);
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as { update: { config: string } };
const config = JSON.parse(
call.update.config.replace(/^encrypted:/, ""),
) as { headerValue?: string };
expect(config.headerValue).toBe("secret");
});
});
@@ -1,226 +0,0 @@
/**
* Enabling a channel the operator switched off instance-wide is refused.
*
* The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` /
* `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching
* refusal on the way in, a user would still see the toggle flip, the row
* would still say `enabled = true`, and nothing would ever arrive. These
* tests pin the refusal and the absence of any write behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
notificationChannel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
pushSubscription: { upsert: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const setTelegramWebhookMock = vi.fn().mockResolvedValue(true);
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args),
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT as putNtfy } from "../ntfy/route";
import { PUT as putTelegram } from "../telegram/route";
import { POST as postWebPush } from "../../notifications/web-push/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const WEB_PUSH_SUBSCRIPTION = {
endpoint: "https://push.example.com/subscription/abc",
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) },
};
function stubSettings(over: Record<string, boolean>) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
...over,
} as never);
}
function request(path: string, method: string, body: unknown) {
return new Request(`http://localhost${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}',
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null);
});
describe("ntfy enable against the instance-wide switch", () => {
it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
data: null,
error: expect.stringContaining("disabled on this instance"),
});
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("still allows disabling the channel while the switch is off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: false }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: false },
});
});
it("allows enabling while the switch is on", async () => {
stubSettings({ ntfyGlobal: true });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
});
});
describe("Telegram enable against the instance-wide switch", () => {
it("refuses with 403, registers no webhook, and writes nothing", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it("refuses a full-config save that enables the channel", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", {
botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii",
chatId: "9876543",
enabled: true,
}) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("Web Push subscribe against the instance-wide switch", () => {
it("refuses with 403 and stores no subscription", async () => {
stubSettings({ webPushGlobal: false });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(403);
expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
});
it("stores the subscription while the switch is on", async () => {
stubSettings({ webPushGlobal: true });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(200);
expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1);
});
});
@@ -1,138 +0,0 @@
/**
* POST /api/settings/email/test — a failed send names its cause.
*
* Before this the route answered every failure with a bare 500, so the card
* read "Test failed" whether the mail server refused the login, timed out,
* or rejected the recipient.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { notificationChannel: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const sendViaEmailMock = vi.fn();
vi.mock("@/lib/notifications/senders/email", () => ({
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: () => true,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../email/test/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function post(): Promise<Response> {
return (POST as (request: Request) => Promise<Response>)(
new Request("http://localhost/api/settings/email/test", {
method: "POST",
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"you@example.com"}',
} as never);
});
describe("POST /api/settings/email/test", () => {
it.each([
["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
["upstream_rejected", 550, "refused the message (SMTP 550)"],
])(
"answers an SMTP rejection with 502, %s and the reply code",
async (failureCode, smtpCode, sentence) => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: smtpCode >= 500,
reason: "email_smtp_5xx",
failureCode,
smtpCode,
});
const response = await post();
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: failureCode,
smtpCode,
});
expect(json.error).toContain(sentence);
},
);
it("answers a timeout with 502 and the timeout code", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
failureCode: "timeout",
});
const response = await post();
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
});
it("keeps a 500 for a failure the sender could not name", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
});
const response = await post();
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
});
it("answers a delivered test with 200", async () => {
sendViaEmailMock.mockResolvedValue({ ok: true });
const response = await post();
expect(response.status).toBe(200);
});
});
@@ -1,510 +1,17 @@
/**
* #947 — a webhook or ntfy target on a private network, at the four routes
* that meet it.
* #947: the test button and the dispatcher must reach the SAME sender
* function for a webhook or ntfy target, so the private-origin decision
* cannot differ between the button that says "works" and the reminder that
* then does not arrive. A structural check over the sources.
*
* The save routes evaluate the same policy the senders do, so a private
* target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that
* names the reason (`meta.errorCode = private_origin_not_approved`) instead
* of a generic "Invalid data", and a listed literal address saves although
* the plain public floor would refuse it. The test routes forward the
* sender's own refusal as a 422 with the same code instead of the bare 500
* that made the reporter read the wide-event log to learn it was policy, and
* answer a relay's error status with a 502 that carries the status and what
* the relay said.
*
* The last block is the structural half: the test button and the dispatcher
* must reach the SAME sender function, so the decision cannot differ between
* the button that says "works" and the reminder that then does not arrive.
* The behaviour at the routes (save-time refusals, the test routes' 422 and
* 502 answers) is proven against the real database in
* `tests/integration/routes/settings-channels.test.ts` and
* `tests/integration/routes/settings-channel-tests.test.ts`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/app-settings", () => ({
isChannelGloballyEnabled: vi.fn().mockResolvedValue(true),
}));
const sendViaWebhookMock = vi.fn();
const sendViaNtfyMock = vi.fn();
vi.mock("@/lib/notifications/senders/webhook", () => ({
sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args),
}));
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
const emitIfSampledMock = vi.fn();
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT as putWebhook } from "../webhook/route";
import { PUT as putNtfy } from "../ntfy/route";
import { POST as testWebhook } from "../webhook/test/route";
import { POST as testNtfy } from "../ntfy/test/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
type Route = (request: Request) => Promise<Response>;
function put(path: string, body: unknown): Promise<Response> {
const route = (path === "webhook" ? putWebhook : putNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function post(path: string): Promise<Response> {
const route = (path === "webhook" ? testWebhook : testNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}/test`, {
method: "POST",
}),
);
}
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
vi.clearAllMocks();
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
afterEach(() => {
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
describe("PUT /api/settings/webhook — private targets", () => {
it("refuses an unlisted private address with the reason, and stores nothing", async () => {
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.data).toBeNull();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("refuses a sub-host of a listed origin: a grant names one origin only", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
const response = await put("webhook", {
url: "https://push.gotify.example.com/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private address the public floor alone would refuse", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080";
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
expect(call.create.config).toBe(
'encrypted:{"url":"http://10.0.0.5:8080/message"}',
);
});
it("keeps the plain shape refusal for a body that is not a private-origin case", async () => {
const response = await put("webhook", { url: 42, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe("Invalid data");
// No private-origin code: only the status's generic one.
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.invalid",
});
});
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
// Host networking: an exact loopback origin with its port is a grant like
// any other. Unlisted, it is refused with the code that says "list it",
// because listing it does open it.
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
const refused = await put("webhook", {
url: "http://127.0.0.1:8080/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://localhost:8080/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => {
// This used to pin a dead end: the route said "list it", the parser
// dropped the listing, and the next save said "list it" again. A target
// no grant can open gets its own code and a message that says so.
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://169.254.169.254, http://0.0.0.0:8080";
for (const url of [
"http://169.254.169.254/latest",
"http://0.0.0.0:8080/message",
"http://[fe80::1]:8080/message",
]) {
const response = await put("webhook", { url, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(json.details.issues.length).toBeGreaterThan(0);
}
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves an mDNS name the input floor refuses, once the operator lists it", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local";
const refused = await put("webhook", {
url: "http://ntfy.local/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://gotify.local/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
});
describe("PUT /api/settings/ntfy — private servers", () => {
it("refuses an unlisted private server with the reason, and stores nothing", async () => {
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("refuses a sibling port of a listed server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:9090",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
});
});
describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => {
beforeEach(() => {
vi.mocked(prisma.notificationChannel.findUnique).mockImplementation(
(async (args: { where: { userId_type: { type: string } } }) =>
args.where.userId_type.type === "WEBHOOK"
? { config: 'encrypted:{"url":"https://gotify.lan/message"}' }
: {
config:
'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}',
}) as never,
);
});
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: answers a private-origin refusal with 422 and the code, not a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_approved",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: forwards the not-grantable code with a message that does not say list it (M1)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_grantable",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_400`,
statusCode: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
const response = await post(path);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "upstream_rejected",
upstreamStatus: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
expect(json.error).toContain("HTTP 400");
// A user's relay refusing is not an operator error: the event is warn.
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "warn" }),
);
},
);
it.each([
[401, "credentials_rejected"],
[403, "credentials_rejected"],
[404, "endpoint_not_found"],
[410, "endpoint_not_found"],
[415, "upstream_rejected"],
[422, "upstream_rejected"],
[429, "rate_limited"],
[503, "upstream_error"],
[307, "redirected"],
])(
"webhook: an upstream %i maps to %s, without a body when the sender dropped it",
async (status, code) => {
sendViaWebhookMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: `webhook_${status}`,
statusCode: status,
});
const response = await post("webhook");
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: code,
upstreamStatus: status,
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a timeout without a status is a 502 with the timeout code",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
failureCode: "timeout",
});
const response = await post(path);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: only a failure the sender could not name stays a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
});
const response = await post(path);
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "error" }),
);
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])("%s: a delivered test is a 200", async (path, sender) => {
sender.mockResolvedValue({ ok: true, statusCode: 200 });
const response = await post(path);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { sent: true },
error: null,
});
});
});
import { describe, expect, it } from "vitest";
describe("the test button and the dispatcher share one sender", () => {
const SRC = join(process.cwd(), "src");
@@ -1,202 +0,0 @@
/**
* v1.4.27 — settings/telegram/test locale path.
*
* Asserts the per-user Telegram test message renders in the user's
* persisted `User.locale` rather than the hardcoded English string
* the pre-fix route used. The actual translator behaviour is covered
* by `notifications/__tests__/admin-locale.test.ts`; this file is
* the route-level smoke test that confirms the wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ ok: true, data }),
apiError: (message: string, status: number) => ({
ok: false,
error: message,
status,
}),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => `decrypted(${s})`,
}));
const sendTelegramMessageMock = vi.fn();
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const tCalls: Array<{ locale: string; key: string }> = [];
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: (locale: string) => ({
locale,
t: (key: string) => {
tCalls.push({ locale, key });
const fakeStrings: Record<string, Record<string, string>> = {
de: {
"notifications.user.telegramTestBody":
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
},
en: {
"notifications.user.telegramTestBody":
"HealthLog: connection successful. Telegram notifications are active.",
},
fr: {
"notifications.user.telegramTestBody":
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
},
};
return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key;
},
}),
}));
import { POST } from "../telegram/test/route";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
tCalls.length = 0;
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
sendTelegramMessageMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("POST /api/settings/telegram/test — locale-aware message", () => {
it("sends the German body when User.locale=de", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
);
expect(tCalls).toEqual([
{
locale: "de",
key: "notifications.user.telegramTestBody",
},
]);
expect((result as { ok: boolean }).ok).toBe(true);
});
it("sends the French body when User.locale=fr", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "fr",
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
);
});
it("falls back to the default locale (en) when User.locale is null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: null,
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connection successful. Telegram notifications are active.",
);
expect(tCalls[0].locale).toBe("en");
});
it("falls back to the default locale (en) when User.locale is unsupported", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "klingon",
} as never);
await (POST as () => Promise<unknown>)();
expect(tCalls[0].locale).toBe("en");
});
it("returns a 422 error when bot token is not configured", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: null,
telegramChatId: null,
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(422);
});
it("returns a 429 error when rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(429);
});
it("returns a 422 error when the Telegram send fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "en",
} as never);
sendTelegramMessageMock.mockResolvedValueOnce({ ok: false });
const result = await (POST as () => Promise<unknown>)();
expect((result as { status: number }).status).toBe(422);
});
});
@@ -1,194 +0,0 @@
/**
* The webhook payload format at the settings route (#947).
*
* Absent means generic, so a config saved before the choice existed keeps
* sending the body it always sent. Only the Gotify choice is written, an
* omitted format keeps the stored one, and the GET reports the resolved
* value rather than leaving the client to guess the default.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../webhook/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
type Route = (request: Request) => Promise<Response>;
function put(body: unknown): Promise<Response> {
return (PUT as Route)(
new Request("http://localhost/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function get(): Promise<Response> {
return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
}
function stored(): string {
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
return call.create.config as string;
}
function existing(config: Record<string, unknown>) {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
enabled: true,
config: `encrypted:${JSON.stringify(config)}`,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
describe("PUT /api/settings/webhook — payload format", () => {
it("stores the Gotify choice", async () => {
const response = await put({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
enabled: true,
});
expect(response.status).toBe(200);
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
});
});
it("writes nothing for generic, so the stored config keeps its old shape", async () => {
await put({
url: "https://relay.example.com/hook",
format: "generic",
enabled: true,
});
expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
});
it("keeps a stored Gotify choice when the body omits the field", async () => {
existing({
url: "https://gotify.example.com/message",
headerValue: "AbCdEfGh123",
format: "gotify",
});
await put({
url: "https://gotify.example.com/message",
headerValue: "NewToken456",
enabled: true,
});
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
headerValue: "NewToken456",
format: "gotify",
});
});
it("switches a Gotify channel back to generic when asked", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
await put({
url: "https://gotify.example.com/message",
format: "generic",
enabled: true,
});
expect(stored()).toBe(
'encrypted:{"url":"https://gotify.example.com/message"}',
);
});
it("refuses a format it does not know", async () => {
const response = await put({
url: "https://relay.example.com/hook",
format: "discord",
enabled: true,
});
expect(response.status).toBe(422);
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
});
describe("GET /api/settings/webhook — payload format", () => {
it("reports generic for a config saved before the choice existed", async () => {
existing({ url: "https://relay.example.com/hook" });
const json = await (await get()).json();
expect(json.data.format).toBe("generic");
});
it("reports the stored Gotify choice", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
const json = await (await get()).json();
expect(json.data.format).toBe("gotify");
});
it("reports generic when no channel exists", async () => {
const json = await (await get()).json();
expect(json.data).toEqual({
enabled: false,
url: "",
headerName: "",
hasHeaderValue: false,
format: "generic",
});
});
});
@@ -1,119 +0,0 @@
/**
* v1.16.11 — reminder-thresholds GET: the operator-level late / missed
* minutes ride along unchanged; the new `lowStockRunwayDays` field is
* PER-USER (from `notificationPrefs.medication`), so the endpoint
* reflects whatever the notification-prefs PATCH persisted — the read
* half of the settings round-trip.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkGet(): Request {
return new Request("http://localhost/api/settings/reminder-thresholds");
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
} as never);
});
describe("GET /api/settings/reminder-thresholds", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
});
it("returns the default low-stock threshold (7) for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
reorderLeadDays: number;
};
};
expect(env.data).toEqual({
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays: 7,
// v1.17.0 — the per-user reorder lead default rides along.
reorderLeadDays: 10,
});
});
it("reflects the per-user threshold persisted by the prefs PATCH", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 21 } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(21);
});
it("reflects the OFF state as null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: null } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(null);
});
});
@@ -1,150 +0,0 @@
/**
* The webhook test button against a Discord webhook URL, through the real
* sender with only the network mocked.
*
* The mocked target answers the way Discord does: 400 with code 50006
* ("Cannot send an empty message") for a body without `content` or `embeds`,
* 204 otherwise. The generic body used to carry neither, so the test button
* and every reminder failed against a Discord URL the settings card names.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST as postTest } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const DISCORD_URL =
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
/** Answers the way Discord's execute-webhook endpoint does. */
function discordAnswer(_url: string, init: { body: string }): Response {
const body = JSON.parse(init.body) as Record<string, unknown>;
const content = typeof body.content === "string" ? body.content : "";
if (content.trim().length === 0 && body.embeds === undefined) {
return new Response(
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (content.length > 2000) {
return new Response(
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
return new Response(null, { status: 204 });
}
const POST = postTest as (request: Request) => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" },
} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
} as never);
safeFetchMock.mockImplementation(
async (url: string, init: { body: string }) => discordAnswer(url, init),
);
});
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
it("delivers: the generic body carries the content Discord requires", async () => {
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toEqual({ sent: true });
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const [url, init] = safeFetchMock.mock.calls[0] as [
string,
{ body: string },
];
expect(url).toBe(DISCORD_URL);
const sent = JSON.parse(init.body);
expect(sent.content).toBe(
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
);
expect(sent.allowed_mentions).toEqual({ parse: [] });
});
it("passes Discord's own error through when the target still refuses", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
{ status: 404, headers: { "Content-Type": "application/json" } },
),
);
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta.upstreamStatus).toBe(404);
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
});
});
@@ -1,311 +0,0 @@
/**
* Who may call `POST /api/workouts/batch` (#1054).
*
* The route names `workouts:write`, so the set of credentials it admits is:
* a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying
* exactly that scope. Every other narrow token — `measurements:write`
* included, however adjacent it looks — is refused 403 by the fail-closed
* default.
*
* The real `requireAuth` and the real `resolveBearerToken` run here; only the
* token row lookup is stubbed, so the admission decision under test is the
* production one rather than a re-implementation of it.
*
* The second half pins what a scoped caller writes: its rows are `EXTERNAL`,
* resolved from the credential, and a body naming any source is refused 422
* rather than relabelled. A session and a wildcard token keep the source they
* send.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
workout: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutRoute: { createMany: vi.fn() },
workoutSamples: { createManyAndReturn: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { workout: unknown });
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../batch/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
const USER = {
id: "user-1",
username: "tester",
role: "USER" as const,
sourcePriorityJson: null,
};
const TOKEN = "hlk_" + "c".repeat(64);
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function workout(
externalId: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
sportType: "running",
startedAt: "2026-09-20T06:30:00.000Z",
endedAt: "2026-09-20T07:15:00.000Z",
externalId,
...overrides,
};
}
/** Present `TOKEN` as a Bearer whose row carries `permissions`. */
function armToken(permissions: string[]) {
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(headers).mockResolvedValue({
get: (name: string) =>
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
} as never);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: USER.id,
permissions,
revoked: false,
expiresAt: new Date(Date.now() + 86_400_000),
} as never);
}
function armSession() {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: USER,
} as never);
}
/** The rows the route handed to `INSERT ... RETURNING`. */
function insertedRows(): Array<Record<string, unknown>> {
return vi
.mocked(prisma.workout.createManyAndReturn)
.mock.calls.flatMap(([args]) => {
const data = (args as { data: unknown }).data;
return (Array.isArray(data) ? data : [data]) as Array<
Record<string, unknown>
>;
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64));
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
// The refusal path chains `.catch` on the audit write, so the reset mock
// has to hand back a promise again.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// One row serves both lookups: the resolver's user fetch and the route's
// source-priority read.
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createManyAndReturn).mockImplementation(
(async (args: { data: Array<Record<string, unknown>> }) =>
args.data.map((row, index) => ({
id: `w-${index}`,
source: row.source,
externalId: row.externalId,
}))) as never,
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("POST /api/workouts/batch — which credentials it admits", () => {
it("admits a narrow workouts:write token", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.inserted).toBe(1);
});
it("still admits a cookie session", async () => {
armSession();
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it("still admits a wildcard token", async () => {
armToken(["*"]);
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it.each([
["measurements:write", [MEASUREMENTS_WRITE_SCOPE]],
["documents:write", [DOCUMENTS_WRITE_SCOPE]],
["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]],
["health:read", ["health:read"]],
])("refuses a %s token with 403 and writes nothing", async (_, perms) => {
armToken(perms);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(403);
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — rate limit buckets", () => {
function lastBucket(): string {
const calls = vi.mocked(checkRateLimit).mock.calls;
return calls[calls.length - 1][0] as string;
}
it("counts a workouts:write token apart from the phone", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const bridge = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
const phone = lastBucket();
expect(bridge).not.toBe(phone);
expect(bridge).toContain(USER.id);
expect(phone).toContain(USER.id);
});
it("keeps a session and a wildcard token in the same bucket", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
const session = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
expect(lastBucket()).toBe(session);
});
});
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
// Two sessions of the day: EXTERNAL ranks on the workout ladder, so two
// rows of one session would land once.
const res = await POST(
makeRequest({
workouts: [
workout("s-1"),
workout("s-2", {
startedAt: "2026-09-20T18:00:00.000Z",
endedAt: "2026-09-20T18:45:00.000Z",
}),
],
}),
);
expect(res.status).toBe(200);
const rows = insertedRows();
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]);
});
it("probes for duplicates in the EXTERNAL namespace", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const probe = vi
.mocked(prisma.workout.findMany)
.mock.calls.map(([args]) => args as { where?: { OR?: unknown } })
.find((args) => args.where?.OR !== undefined);
expect(probe?.where?.OR).toEqual([
{ source: "EXTERNAL", externalId: "s-1" },
]);
});
it.each(["APPLE_HEALTH", "MANUAL"])(
"refuses a body naming %s with 422 and writes nothing",
async (source) => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(
makeRequest({
workouts: [workout("s-1"), workout("s-2", { source })],
}),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted");
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
},
);
it("leaves the source a wildcard caller sends alone", async () => {
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]);
});
it("leaves the source a session sends alone", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]);
});
});
@@ -1,213 +0,0 @@
/**
* Unit suite for `DELETE /api/workouts/{id}`.
*
* Pins:
* - a workout entered by hand is removed, with the personal records keyed
* on it, and a silent detection pass re-derives the previous best;
* - a synced workout answers 409 and nothing is touched, because the next
* sync would write it back;
* - another user's row and a missing row answer 404;
* - no session answers 401.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const tx = {
$queryRaw: vi.fn(async () => []),
personalRecord: { deleteMany: vi.fn() },
workout: { delete: vi.fn() },
};
vi.mock("@/lib/db", () => ({
prisma: {
workout: { findUnique: vi.fn() },
$transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MANUAL_ROW = {
id: "w-1",
userId: "user-1",
source: "MANUAL" as const,
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
startedAt: new Date("2026-09-01T07:00:00Z"),
sportType: "running",
};
function del(id = "w-1") {
return DELETE(
new NextRequest(`http://localhost/api/workouts/${id}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id }) },
);
}
describe("DELETE /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
tx.workout.delete.mockResolvedValue({ id: "w-1" });
});
it("removes a workout entered by hand", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
const res = await del();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
expect(auditLog).toHaveBeenCalledWith(
"workout.delete",
expect.objectContaining({ userId: "user-1" }),
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("takes the personal records the workout set with it and re-derives the best", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
const res = await del();
expect(res.status).toBe(200);
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: MANUAL_ROW.externalId,
},
});
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("keys a record without an external id on the session's start", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
externalId: null,
} as never);
await del();
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
source: "MANUAL",
// Only the running slots: a cycling record set at the same instant
// is not this workout's.
metricSlot: {
in: [
"longest_run_duration",
"longest_distance_run",
"fastest_5km_time",
],
},
externalId: null,
achievedAt: MANUAL_ROW.startedAt,
},
});
// Nothing was removed, so nothing needs re-deriving.
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it("takes the personal-records lock before it deletes anything", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
await del();
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(
tx.personalRecord.deleteMany.mock.invocationCallOrder[0],
);
expect(lockOrder).toBeLessThan(
tx.workout.delete.mock.invocationCallOrder[0],
);
});
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
"refuses a %s workout with 409 and touches nothing",
async (source) => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
source,
} as never);
const res = await del();
expect(res.status).toBe(409);
const body = await res.json();
expect(body.data).toBeNull();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.workout.delete).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
},
);
it("answers 404 for another user's workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
userId: "someone-else",
} as never);
const res = await del();
expect(res.status).toBe(404);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("answers 404 when there is no such workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
const res = await del("missing");
expect(res.status).toBe(404);
});
it("answers 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await del();
expect(res.status).toBe(401);
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
});
@@ -1,774 +0,0 @@
/**
* Unit suite for `GET /api/workouts/{id}` — single-workout detail.
*
* v1.4.32 — paired with the list endpoint so the iOS detail screen +
* the `/insights/workouts/[id]` web page consume one envelope per
* workout. The test pins:
* - 200 + iOS-contract field names for a row the caller owns;
* - 401 without a session;
* - 404 (existence-channel sealed) when the row belongs to another
* user;
* - 404 when no row exists;
* - `canonicalId` resolves to the cluster winner — the requested id
* when the row is the canonical pick, the winner id when it is a
* non-canonical twin;
* - the optional `route` field flows through GeoJSON geometry when a
* `WorkoutRoute` row is attached, and is null when missing;
* - `aiInsight` is a pure READ: it serves a stored paragraph when one
* exists, null when none does, and never generates one;
* - `dayKey` dates the session in the user's timezone, so a session that
* starts late in the UTC evening lands on the local day the person
* actually trained on.
*/
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
measurement: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
};
});
// The Activity Insight paragraph follows the `workoutInsights` capability.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/workouts/w-1", {
method: "GET",
});
}
function makeParams(id: string) {
return { params: Promise.resolve({ id }) };
}
/**
* Session day, ten days before the test run. The detail route's HR-curve
* enrichment only reaches the pulse-window fallback inside the 90-day
* dense-intraday retention horizon (`DENSE_INTRADAY_RETENTION_DAYS`), so a
* fixed calendar day silently ages out of that branch and leaves the
* fallback assertions green without exercising it.
*/
const SESSION_DAY = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const BASE_ROW = {
id: "w-1",
userId: "user-1",
sportType: "RUNNING",
startedAt: new Date(`${SESSION_DAY}T07:00:00Z`),
endedAt: new Date(`${SESSION_DAY}T07:30:00Z`),
durationSec: 1800,
totalDistanceM: 5000,
totalEnergyKcal: 320,
avgHeartRate: 145,
maxHeartRate: 170,
minHeartRate: 110,
stepCount: 5800,
elevationM: 12.5,
pauseDurationSec: null,
source: "APPLE_HEALTH" as const,
externalId: "ext-w-1",
externalSourceVersion: null,
metadata: { hkVersion: "1" },
createdAt: new Date(`${SESSION_DAY}T07:30:00Z`),
updatedAt: new Date(`${SESSION_DAY}T07:30:00Z`),
route: null,
insight: null,
};
describe("GET /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
// #67 — the enrichment reads: the HR-curve pulse-window fallback
// (`measurement.findMany`) and the sport-context average (a SECOND
// `workout.findMany`). Default both to empty; each test's
// `mockResolvedValueOnce` still wins for the cluster call.
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
// clearAllMocks wipes the factory default; re-seed the gate to pass.
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
// The timezone resolver caches per user for 60 s in process; drop the
// entry so a test that pins a zone is not served the previous one.
invalidateUserTimezone(SESSION_OK.user.id);
});
it("returns the workout with iOS-contract field names", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe("w-1");
expect(body.data.distanceM).toBe(5000);
expect(body.data.activeEnergyKcal).toBe(320);
expect(body.data.avgHr).toBe(145);
expect(body.data.maxHr).toBe(170);
expect(body.data.canonicalId).toBe("w-1");
expect(body.data.route).toBeNull();
// Nothing older on record: absence, not a link to nowhere.
expect(body.data.previousWorkoutId).toBeNull();
});
it("points previousWorkoutId at the canonical session before this one", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany)
// 1) the deep-link cluster
.mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never)
// 2) the sport-context baseline
.mockResolvedValueOnce([] as never)
// 3) the older sessions, newest first
.mockResolvedValueOnce([
{
id: "w-0",
source: "APPLE_HEALTH",
startedAt: new Date("2026-05-13T07:00:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
// The same session as seen by the second watch. It must never win
// the link — the list would never show it.
{
id: "w-0-twin",
source: "WITHINGS",
startedAt: new Date("2026-05-13T07:01:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
{
id: "w-older",
source: "APPLE_HEALTH",
startedAt: new Date("2026-05-10T07:00:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.previousWorkoutId).toBe("w-0");
});
it("dates the session by the user's timezone, not by UTC", async () => {
// 20:00 UTC is already the NEXT calendar day in Auckland. A client that
// sliced the ISO timestamp would read the session's UTC day and pull the wrong
// day's pulse, sleep and mood around the session.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
timezone: "Pacific/Auckland",
} as never);
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
startedAt: new Date(`${SESSION_DAY}T20:00:00Z`),
endedAt: new Date(`${SESSION_DAY}T20:30:00Z`),
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: new Date(`${SESSION_DAY}T20:00:00Z`),
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
const nextLocalDay = new Date(
new Date(`${SESSION_DAY}T00:00:00Z`).getTime() + 24 * 60 * 60 * 1000,
)
.toISOString()
.slice(0, 10);
expect(body.data.dayKey).toBe(nextLocalDay);
});
it("enriches the Apple winner with only matched WHOOP HR and zones", async () => {
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.01],
],
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
avgHeartRate: null,
maxHeartRate: null,
metadata: { hkVersion: "1", owner: "apple" },
route: {
id: "wr-apple",
geometry: routeGeometry,
sampleTimestamps: null,
createdAt: BASE_ROW.createdAt,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
avgHeartRate: null,
maxHeartRate: null,
metadata: { hkVersion: "1", owner: "apple" },
},
{
id: "w-whoop",
source: "WHOOP",
startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000),
sportType: "RUNNING",
avgHeartRate: 148,
maxHeartRate: 181,
metadata: {
zoneDurations: {
zone_one_milli: 60_000,
zone_two_milli: 300_000,
},
whoopWorkoutStrain: 12.3,
},
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data).toMatchObject({
id: "w-1",
source: "APPLE_HEALTH",
canonicalId: "w-1",
avgHr: 148,
maxHr: 181,
route: { geometry: routeGeometry },
metadata: { hkVersion: "1", owner: "apple" },
});
expect(body.data.metadata).not.toHaveProperty("whoopWorkoutStrain");
expect(body.data.metadata).not.toHaveProperty("zoneDurations");
expect(body.data.zones.model).toBe("whoop");
expect(body.data.hrSeries).toBeNull();
});
it("does not overwrite the canonical winner's existing HR values", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
avgHeartRate: BASE_ROW.avgHeartRate,
maxHeartRate: BASE_ROW.maxHeartRate,
metadata: BASE_ROW.metadata,
},
{
id: "w-whoop",
source: "WHOOP",
startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000),
sportType: "RUNNING",
avgHeartRate: 155,
maxHeartRate: 190,
metadata: null,
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data.avgHr).toBe(145);
expect(body.data.maxHr).toBe(170);
});
it("returns 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValueOnce(null);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(401);
});
it("returns 403 module.disabled when the workouts module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: new Response(
JSON.stringify({ data: null, error: "Module disabled" }),
{ status: 403 },
),
} as never);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(403);
// The detail query never runs once the gate refuses.
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
userId: "user-2",
} as never);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(404);
});
it("returns 404 when no row exists", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(null);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(404);
});
it("resolves canonicalId to the Apple Health twin when the requested row is the Withings sibling", async () => {
// Same 5-minute slot + sportType, but the caller asked for the
// Withings row. The picker's default ladder makes Apple Health win
// the cluster, so canonicalId points at the Apple row.
const withingsRow = {
...BASE_ROW,
id: "w-withings",
source: "WITHINGS" as const,
externalId: "withings-1",
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
withingsRow as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-apple",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
{
id: "w-withings",
source: "WITHINGS",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-withings"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe("w-withings");
expect(body.data.canonicalId).toBe("w-apple");
});
it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => {
const startMs = BASE_ROW.startedAt.getTime();
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
t: new Date(startMs + i * 45_000).toISOString(),
hr: 140 + (i % 6),
}));
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
// WHOOP device zones win → zones render even without profile age.
metadata: {
zoneDurations: {
zone_one_milli: 60_000,
zone_two_milli: 300_000,
zone_three_milli: 600_000,
zone_four_milli: 200_000,
zone_five_milli: 40_000,
},
},
route: {
id: "wr-1",
geometry: {
type: "LineString",
coordinates: [
[11, 49],
[11.01, 49.01],
],
},
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
],
createdAt: BASE_ROW.createdAt,
},
samples: { sampleCount: storedSamples.length, samples: storedSamples },
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
const body = await res.json();
expect(res.status).toBe(200);
// Stored series wins → provenance is the native workout series.
expect(body.data.hrSeries.source).toBe("workout_series");
expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2);
// WHOOP device zones.
expect(body.data.zones.model).toBe("whoop");
// Additive keys present; reserved seam is null.
expect(body.data).toHaveProperty("splits");
expect(body.data).toHaveProperty("sportContext");
expect(body.data.aiInsight).toBeNull();
// compact=1 drops the raw blobs but keeps count + geometry.
expect(body.data.samples.sampleCount).toBe(40);
expect(body.data.samples.samples).toBeNull();
expect(body.data.route.geometry).not.toBeNull();
expect(body.data.route.sampleTimestamps).toBeNull();
});
it("exposes the WorkoutRoute geometry when present", async () => {
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.01],
],
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
route: {
id: "wr-1",
geometry: routeGeometry,
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
],
createdAt: BASE_ROW.createdAt,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data.route).not.toBeNull();
expect(body.data.route.geometry).toEqual(routeGeometry);
expect(body.data.route.sampleTimestamps).toEqual([
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
]);
});
describe("v1.39.4 — sealed track", () => {
const TEST_KEY = "c".repeat(64);
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.005, 49.0],
[11.01, 49.0],
],
};
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
function mockRoute(route: Record<string, unknown>) {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
route: {
id: "wr-1",
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:02:00Z`,
`${SESSION_DAY}T07:04:00Z`,
],
createdAt: BASE_ROW.createdAt,
...route,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
}
it("serves a sealed track in the same shape as a readable one, splits included", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
const legacy = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
mockRoute({
geometry: null,
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const sealed = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
expect(sealed.route).toEqual(legacy.route);
expect(sealed.route.geometry).toEqual(routeGeometry);
expect(sealed.splits).toEqual(legacy.splits);
});
it("prefers the ciphertext over a readable column on the same row", async () => {
mockRoute({
geometry: { type: "LineString", coordinates: [[0, 0]] },
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const body = await (await GET(makeRequest(), makeParams("w-1"))).json();
expect(body.data.route.geometry).toEqual(routeGeometry);
});
it("selects the sealed column", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
await GET(makeRequest(), makeParams("w-1"));
const args = vi.mocked(prisma.workout.findUnique).mock.calls.at(-1)?.[0];
expect(
(args as { include: { route: { select: Record<string, boolean> } } })
.include.route.select.geometryEncrypted,
).toBe(true);
});
});
});
/**
* The Activity-Insight read.
*
* The load-bearing property is what is ABSENT: this route has no enqueue, no
* provider resolution and no fallback. A workout with no stored row serves null
* and the page renders nothing — which is every workout that predates the
* feature, every re-synced one, and every one on a provider-less install.
*/
describe("GET /api/workouts/{id} — aiInsight", () => {
// A throwaway test key. The route decrypts a stored paragraph, so the suite
// needs a configured cipher; `_resetCryptoCacheForTests` keeps the stubbed
// key from leaking into the neighbouring describes.
const TEST_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
});
async function get() {
return GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
}
it("serves null when the workout has no stored paragraph", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: null,
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
});
it("serves the decrypted paragraph when one exists", async () => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
const generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`);
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: encryptToBytes("A steady, aerobic-leaning run."),
generatedAt,
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toEqual({
paragraph: "A steady, aerobic-leaning run.",
generatedAt: generatedAt.toISOString(),
});
expect(aiCapabilityToServe).toHaveBeenCalledWith(
"user-1",
"workoutInsights",
);
expect(body.data.ai).toEqual(AI_AVAILABLE);
});
it.each([
"operator_disabled",
"user_disabled",
"module_disabled",
"consent_required",
"no_provider",
] as const)(
"does not read or expose the stored paragraph while workoutInsights is %s",
async (reason) => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
expect(body.data.ai).toEqual(aiUnavailable(reason));
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ insight: false }),
}),
);
},
);
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
// `decrypt` is fail-closed by design. A key rotated away must not turn the
// whole workout-detail page into a 500 over a garnish field.
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([0, 1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const res = await get();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.aiInsight).toBeNull();
// The rest of the payload is untouched — the failure is scoped to the field.
expect(body.data.id).toBe("w-1");
expect(body.data.durationSec).toBe(1800);
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
});