mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
ff9ef0883b
commit
43b86e46da
16 files changed
+2868
-3247
No files matched your search
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* The channel self-tests, `POST /api/settings/{webhook,ntfy,email,telegram}
|
||||
* /test`, against the real database, through the real API kit and the real
|
||||
* senders. Only the far ends are replaced: outbound HTTP at the `safeFetch`
|
||||
* seam (the relay, the ntfy server, Telegram's Bot API) and the SMTP
|
||||
* transport at `nodemailer.createTransport`. What a test press sends is read
|
||||
* off what those seams received.
|
||||
*
|
||||
* Pinned here:
|
||||
* - a delivered test is 200 `{ sent: true }`, and the far end received the
|
||||
* message for the caller's own stored config: the Discord-compatible
|
||||
* generic webhook body, the ntfy JSON publish, the mail to the stored
|
||||
* recipient, the Telegram text in the account's language (German,
|
||||
* French, English for a missing or unknown locale);
|
||||
* - a private target the operator has not listed is 422
|
||||
* `private_origin_not_approved`, one no grant can open is 422
|
||||
* `private_origin_not_grantable`, and in both cases nothing is dialled;
|
||||
* - a relay that answered an error status is 502 with the status mapped to
|
||||
* the card's code, the status itself, and what the relay said when it
|
||||
* said anything; a timeout is 502 `timeout`; an SMTP rejection is 502
|
||||
* with the code and the SMTP reply code; only a failure the sender could
|
||||
* not name is 500 `http.internal`;
|
||||
* - Telegram: no saved credentials is 422 and nothing is sent, a refused
|
||||
* send is 422, and the sixth test inside five minutes is 429.
|
||||
*/
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { SafeFetchError, type SafeFetchOptions } from "@/lib/safe-fetch";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { resetEmailConfigForTesting } from "@/lib/notifications/senders/email-config";
|
||||
import { resetEmailTransporterForTesting } from "@/lib/notifications/senders/email";
|
||||
import { POST as testWebhook } from "@/app/api/settings/webhook/test/route";
|
||||
import { POST as testNtfy } from "@/app/api/settings/ntfy/test/route";
|
||||
import { POST as testEmail } from "@/app/api/settings/email/test/route";
|
||||
import { POST as testTelegram } from "@/app/api/settings/telegram/test/route";
|
||||
|
||||
import { callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
interface SentRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface SentMail {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const far = vi.hoisted(() => ({
|
||||
requests: [] as SentRequest[],
|
||||
/** The far end's answer; a thrown value stands for the network failing. */
|
||||
answer: null as null | ((request: SentRequest) => Response),
|
||||
mails: [] as SentMail[],
|
||||
smtpFailure: null as null | Error,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
||||
return {
|
||||
...actual,
|
||||
safeFetch: async (
|
||||
url: string | URL,
|
||||
init: RequestInit = {},
|
||||
_options?: SafeFetchOptions,
|
||||
) => {
|
||||
const request: SentRequest = {
|
||||
url: String(url),
|
||||
method: init.method ?? "GET",
|
||||
headers: Object.fromEntries(new Headers(init.headers).entries()),
|
||||
body: typeof init.body === "string" ? init.body : "",
|
||||
};
|
||||
far.requests.push(request);
|
||||
if (far.answer) return far.answer(request);
|
||||
return new Response(JSON.stringify({ ok: true, result: {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("nodemailer", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("nodemailer")>();
|
||||
const createTransport = () => ({
|
||||
sendMail: async (mail: SentMail) => {
|
||||
far.mails.push(mail);
|
||||
if (far.smtpFailure) throw far.smtpFailure;
|
||||
return { messageId: "<kit@example.test>" };
|
||||
},
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
createTransport,
|
||||
default: { ...actual.default, createTransport },
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const ENV_KEYS = [
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_FROM",
|
||||
"NOTIFICATION_PRIVATE_ORIGINS",
|
||||
] as const;
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
beforeAll(() => {
|
||||
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||
process.env.SMTP_HOST = "smtp.example.test";
|
||||
process.env.SMTP_PORT = "587";
|
||||
process.env.SMTP_FROM = "healthlog@example.test";
|
||||
resetEmailConfigForTesting();
|
||||
resetEmailTransporterForTesting();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of savedEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
resetEmailConfigForTesting();
|
||||
resetEmailTransporterForTesting();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
far.requests.length = 0;
|
||||
far.answer = null;
|
||||
far.mails.length = 0;
|
||||
far.smtpFailure = null;
|
||||
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
|
||||
});
|
||||
|
||||
async function withChannel(
|
||||
type: "WEBHOOK" | "NTFY" | "EMAIL",
|
||||
config: Record<string, unknown>,
|
||||
) {
|
||||
const user = await makeUser();
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type,
|
||||
enabled: true,
|
||||
config: encrypt(JSON.stringify(config)),
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
const TEST_ROUTES = {
|
||||
webhook: { handler: testWebhook, route: "/api/settings/webhook/test" },
|
||||
ntfy: { handler: testNtfy, route: "/api/settings/ntfy/test" },
|
||||
email: { handler: testEmail, route: "/api/settings/email/test" },
|
||||
telegram: { handler: testTelegram, route: "/api/settings/telegram/test" },
|
||||
} as const;
|
||||
|
||||
async function press(name: keyof typeof TEST_ROUTES, userId: string) {
|
||||
const { handler, route } = TEST_ROUTES[name];
|
||||
return callRoute<{ sent: boolean }>(handler, {
|
||||
method: "POST",
|
||||
route,
|
||||
auth: await makeSession({ id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
function answerWith(status: number, body: string | null): void {
|
||||
far.answer = () =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
});
|
||||
}
|
||||
|
||||
const PUBLIC_TARGETS = {
|
||||
webhook: {
|
||||
type: "WEBHOOK",
|
||||
config: { url: "https://relay.example.com/hook" },
|
||||
},
|
||||
ntfy: {
|
||||
type: "NTFY",
|
||||
config: { serverUrl: "https://ntfy.example.com", topic: "health" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PRIVATE_TARGETS = {
|
||||
webhook: { type: "WEBHOOK", config: { url: "http://10.0.0.5:8080/message" } },
|
||||
ntfy: {
|
||||
type: "NTFY",
|
||||
config: { serverUrl: "http://192.168.1.20:8080", topic: "health" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const UNGRANTABLE_TARGETS = {
|
||||
webhook: {
|
||||
type: "WEBHOOK",
|
||||
config: { url: "http://169.254.169.254/latest" },
|
||||
},
|
||||
ntfy: {
|
||||
type: "NTFY",
|
||||
config: { serverUrl: "http://169.254.169.254", topic: "health" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const RELAYS = ["webhook", "ntfy"] as const;
|
||||
|
||||
describe("POST /api/settings/webhook/test", () => {
|
||||
const DISCORD_URL =
|
||||
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
|
||||
|
||||
it("delivers to a Discord webhook URL: the generic body carries the content Discord requires", async () => {
|
||||
const user = await withChannel("WEBHOOK", { url: DISCORD_URL });
|
||||
// Answers the way Discord's execute-webhook endpoint does: 400 50006 for
|
||||
// a body without `content` or `embeds`, 204 otherwise.
|
||||
far.answer = (request) => {
|
||||
const body = JSON.parse(request.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" } },
|
||||
);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
|
||||
const res = await press("webhook", user.id);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ sent: true });
|
||||
expect(far.requests).toHaveLength(1);
|
||||
expect(far.requests[0].url).toBe(DISCORD_URL);
|
||||
expect(far.requests[0].method).toBe("POST");
|
||||
const sent = JSON.parse(far.requests[0].body) as Record<string, unknown>;
|
||||
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 refuses", async () => {
|
||||
const user = await withChannel("WEBHOOK", { url: DISCORD_URL });
|
||||
answerWith(
|
||||
404,
|
||||
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
|
||||
);
|
||||
|
||||
const res = await press("webhook", user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta?.errorCode).toBe("endpoint_not_found");
|
||||
expect(res.meta?.upstreamStatus).toBe(404);
|
||||
expect(String(res.meta?.upstreamBody)).toContain("Unknown Webhook");
|
||||
});
|
||||
|
||||
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"],
|
||||
])(
|
||||
"maps an upstream %i to %s, without a body when the relay sent none",
|
||||
async (status, code) => {
|
||||
const user = await withChannel("WEBHOOK", PUBLIC_TARGETS.webhook.config);
|
||||
answerWith(status, null);
|
||||
|
||||
const res = await press("webhook", user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: code,
|
||||
upstreamStatus: status,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/settings/{webhook,ntfy}/test", () => {
|
||||
it.each(RELAYS)(
|
||||
"%s: refuses a private target the operator has not listed with 422 and the code, and dials nothing",
|
||||
async (name) => {
|
||||
const { type, config } = PRIVATE_TARGETS[name];
|
||||
const user = await withChannel(type, config);
|
||||
|
||||
const res = await press(name, user.id);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
expect(res.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(far.requests).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(RELAYS)(
|
||||
"%s: refuses a target no grant can open with its own code and a message that does not say list it",
|
||||
async (name) => {
|
||||
const { type, config } = UNGRANTABLE_TARGETS[name];
|
||||
const user = await withChannel(type, config);
|
||||
|
||||
const res = await press(name, user.id);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
expect(res.error).toMatch(/no operator grant can open/);
|
||||
expect(res.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(far.requests).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(RELAYS)(
|
||||
"%s: a relay that answered 400 is a 502 naming the status and what it said",
|
||||
async (name) => {
|
||||
const { type, config } = PUBLIC_TARGETS[name];
|
||||
const user = await withChannel(type, config);
|
||||
answerWith(400, '{"error":"Bad Request","errorCode":400}');
|
||||
|
||||
const res = await press(name, user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
});
|
||||
expect(res.error).toContain("HTTP 400");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(RELAYS)(
|
||||
"%s: a timeout without a status is a 502 with the timeout code",
|
||||
async (name) => {
|
||||
const { type, config } = PUBLIC_TARGETS[name];
|
||||
const user = await withChannel(type, config);
|
||||
far.answer = () => {
|
||||
throw new SafeFetchError("The operation timed out", "timeout");
|
||||
};
|
||||
|
||||
const res = await press(name, user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta?.errorCode).toBe("timeout");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(RELAYS)(
|
||||
"%s: only a failure the sender could not name stays a 500",
|
||||
async (name) => {
|
||||
const { type, config } = PUBLIC_TARGETS[name];
|
||||
const user = await withChannel(type, config);
|
||||
far.answer = () => {
|
||||
throw new TypeError("something unexpected");
|
||||
};
|
||||
|
||||
const res = await press(name, user.id);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "http.internal",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("webhook: a delivered test is a 200 and the relay received the generic body", async () => {
|
||||
const user = await withChannel("WEBHOOK", {
|
||||
url: "https://relay.example.com/hook",
|
||||
headerName: "X-Relay-Key",
|
||||
headerValue: "relay-secret",
|
||||
});
|
||||
|
||||
const res = await press("webhook", user.id);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { sent: true }, error: null });
|
||||
expect(far.requests).toHaveLength(1);
|
||||
expect(far.requests[0].url).toBe("https://relay.example.com/hook");
|
||||
expect(far.requests[0].headers["x-relay-key"]).toBe("relay-secret");
|
||||
expect(JSON.parse(far.requests[0].body)).toMatchObject({
|
||||
title: "HealthLog Test",
|
||||
message:
|
||||
"HealthLog: Connection successful! Webhook notifications are active.",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
});
|
||||
});
|
||||
|
||||
it("ntfy: a delivered test is a 200 and the server received the JSON publish for the stored topic", async () => {
|
||||
const user = await withChannel("NTFY", {
|
||||
serverUrl: "https://ntfy.example.com",
|
||||
topic: "health",
|
||||
authToken: "tk_ntfy",
|
||||
});
|
||||
|
||||
const res = await press("ntfy", user.id);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { sent: true }, error: null });
|
||||
expect(far.requests).toHaveLength(1);
|
||||
expect(far.requests[0].url).toBe("https://ntfy.example.com/");
|
||||
expect(far.requests[0].headers.authorization).toBe("Bearer tk_ntfy");
|
||||
expect(JSON.parse(far.requests[0].body)).toMatchObject({
|
||||
topic: "health",
|
||||
title: "HealthLog Test",
|
||||
message:
|
||||
"HealthLog: Connection successful! ntfy notifications are active.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/settings/email/test", () => {
|
||||
async function emailUser() {
|
||||
return withChannel("EMAIL", { recipient: "you@example.com" });
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
"credentials_rejected",
|
||||
Object.assign(new Error("Invalid login: 535 Authentication failed"), {
|
||||
code: "EAUTH",
|
||||
responseCode: 535,
|
||||
}),
|
||||
535,
|
||||
"rejected the credentials (SMTP 535)",
|
||||
],
|
||||
[
|
||||
"upstream_rejected",
|
||||
Object.assign(new Error("Mailbox unavailable"), {
|
||||
code: "EENVELOPE",
|
||||
responseCode: 550,
|
||||
}),
|
||||
550,
|
||||
"refused the message (SMTP 550)",
|
||||
],
|
||||
])(
|
||||
"answers an SMTP rejection with 502, %s and the reply code",
|
||||
async (code, failure, smtpCode, sentence) => {
|
||||
const user = await emailUser();
|
||||
far.smtpFailure = failure;
|
||||
|
||||
const res = await press("email", user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: code,
|
||||
smtpCode,
|
||||
});
|
||||
expect(res.error).toContain(sentence);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers a timeout with 502 and the timeout code", async () => {
|
||||
const user = await emailUser();
|
||||
far.smtpFailure = Object.assign(new Error("Connection timeout"), {
|
||||
code: "ETIMEDOUT",
|
||||
});
|
||||
|
||||
const res = await press("email", user.id);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.meta?.errorCode).toBe("timeout");
|
||||
});
|
||||
|
||||
it("keeps a 500 for a failure the sender could not name", async () => {
|
||||
const user = await emailUser();
|
||||
far.smtpFailure = new Error("something unexpected");
|
||||
|
||||
const res = await press("email", user.id);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "http.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a delivered test with 200, and the mail went to the stored recipient", async () => {
|
||||
const user = await emailUser();
|
||||
|
||||
const res = await press("email", user.id);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ sent: true });
|
||||
expect(far.mails).toEqual([
|
||||
{
|
||||
from: "healthlog@example.test",
|
||||
to: "you@example.com",
|
||||
subject: "HealthLog Test",
|
||||
text: "HealthLog: Connection successful! Email notifications are active.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/settings/telegram/test", () => {
|
||||
async function telegramUser(locale: string | null) {
|
||||
return makeUser({
|
||||
telegramBotToken: encrypt("123:bot-token"),
|
||||
telegramChatId: "chat-1",
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
function sentText(): unknown {
|
||||
expect(far.requests).toHaveLength(1);
|
||||
expect(far.requests[0].url).toBe(
|
||||
"https://api.telegram.org/bot123:bot-token/sendMessage",
|
||||
);
|
||||
const body = JSON.parse(far.requests[0].body) as {
|
||||
chat_id: string;
|
||||
text: string;
|
||||
};
|
||||
expect(body.chat_id).toBe("chat-1");
|
||||
return body.text;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
"de",
|
||||
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
|
||||
],
|
||||
[
|
||||
"fr",
|
||||
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
|
||||
],
|
||||
[
|
||||
null,
|
||||
"HealthLog: connection successful. Telegram notifications are active.",
|
||||
],
|
||||
[
|
||||
"klingon",
|
||||
"HealthLog: connection successful. Telegram notifications are active.",
|
||||
],
|
||||
])(
|
||||
"sends the body in the account's language (locale %s)",
|
||||
async (locale, text) => {
|
||||
const user = await telegramUser(locale);
|
||||
|
||||
const res = await press("telegram", user.id);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ sent: true });
|
||||
expect(sentText()).toBe(text);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers 422 and sends nothing when no bot token is saved", async () => {
|
||||
const user = await makeUser({ locale: "de" });
|
||||
|
||||
const res = await press("telegram", user.id);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(far.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers 422 when Telegram refuses the send", async () => {
|
||||
const user = await telegramUser("en");
|
||||
far.answer = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
description: "Bad Request: chat not found",
|
||||
}),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const res = await press("telegram", user.id);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("answers the sixth test inside five minutes with 429 and sends nothing for it", async () => {
|
||||
const user = await telegramUser("en");
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect((await press("telegram", user.id)).status).toBe(200);
|
||||
}
|
||||
|
||||
const res = await press("telegram", user.id);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(far.requests).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* `GET /api/settings/reminder-thresholds`, against the real database and
|
||||
* through the real API kit.
|
||||
*
|
||||
* Pinned here:
|
||||
* - an anonymous caller is refused;
|
||||
* - the late and missed minutes are the operator's, read from the
|
||||
* instance settings row, and fall back to 120 / 240 without one;
|
||||
* - the low-stock runway and the reorder lead are the account's own: a
|
||||
* fresh account reads the defaults (7 and 10), and the read reflects
|
||||
* what `PATCH /api/auth/me/notification-prefs` stored, the OFF state
|
||||
* (`null`) included. This is the read half of that settings round trip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET } from "@/app/api/settings/reminder-thresholds/route";
|
||||
import { PATCH as patchPrefs } from "@/app/api/auth/me/notification-prefs/route";
|
||||
|
||||
import { anonymous, callRoute } from "../../kit/call-route";
|
||||
import {
|
||||
makeSession,
|
||||
makeUser,
|
||||
type SessionFixture,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/settings/reminder-thresholds";
|
||||
|
||||
interface Thresholds {
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays: number;
|
||||
}
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
function read(auth: SessionFixture) {
|
||||
return callRoute<Thresholds>(GET, { method: "GET", route: ROUTE, auth });
|
||||
}
|
||||
|
||||
async function setRunway(auth: SessionFixture, days: number | null) {
|
||||
const res = await callRoute(patchPrefs, {
|
||||
method: "PATCH",
|
||||
route: "/api/auth/me/notification-prefs",
|
||||
body: { medication: { lowStockRunwayDays: days } },
|
||||
auth,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
describe("GET /api/settings/reminder-thresholds", () => {
|
||||
it("refuses an anonymous caller with 401", async () => {
|
||||
const res = await callRoute(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
auth: anonymous,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("answers the built-in defaults for a fresh account on an instance without a settings row", async () => {
|
||||
const res = await read(await makeSession(await makeUser()));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 240,
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the operator's late and missed minutes beside the account's own defaults", async () => {
|
||||
await prisma.appSettings.create({
|
||||
data: {
|
||||
id: "singleton",
|
||||
reminderLateMinutes: 90,
|
||||
reminderMissedMinutes: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await read(await makeSession(await makeUser()));
|
||||
|
||||
expect(res.data).toEqual({
|
||||
lateMinutes: 90,
|
||||
missedMinutes: 300,
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("reflects the runway the notification-prefs write stored, and only for that account", async () => {
|
||||
const session = await makeSession(await makeUser());
|
||||
const otherSession = await makeSession(await makeUser());
|
||||
await setRunway(session, 21);
|
||||
|
||||
const own = await read(session);
|
||||
const other = await read(otherSession);
|
||||
|
||||
expect(own.data.lowStockRunwayDays).toBe(21);
|
||||
expect(other.data.lowStockRunwayDays).toBe(7);
|
||||
});
|
||||
|
||||
it("reflects the OFF state as null", async () => {
|
||||
const session = await makeSession(await makeUser());
|
||||
await setRunway(session, null);
|
||||
|
||||
const res = await read(session);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.lowStockRunwayDays).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Who may call `POST /api/workouts/batch`, and what a narrow
|
||||
* `workouts:write` credential writes, against the real database and
|
||||
* through the real API kit (#1054).
|
||||
*
|
||||
* Pinned here, read back from the stored rows:
|
||||
* - the route admits a cookie session, a wildcard token and a narrow token
|
||||
* carrying exactly `workouts:write`; every other narrow token, however
|
||||
* adjacent its scope looks, is 403 and writes nothing;
|
||||
* - a `workouts:write` token counts in a rate-limit bucket of its own, apart
|
||||
* from the phone's, while a session and a wildcard token share one;
|
||||
* - a scoped caller's rows are `EXTERNAL`, resolved from the credential,
|
||||
* and live in their own `(source, externalId)` namespace, so a bridge id
|
||||
* equal to one the phone stored is a new row and its resend a duplicate;
|
||||
* - a scoped body naming any source is 422
|
||||
* `workout.batch.source_not_permitted` and writes nothing, while a session
|
||||
* and a wildcard token keep the source they send.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { POST } from "@/app/api/workouts/batch/route";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
|
||||
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
|
||||
|
||||
import { callRoute, type RouteAuth } from "../../kit/call-route";
|
||||
import {
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
makeWorkout,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/workouts/batch";
|
||||
|
||||
interface BatchResult {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ index: number; status: string }>;
|
||||
}
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** A second session the same day, so the cross-source picker keeps both. */
|
||||
const EVENING = {
|
||||
startedAt: "2026-09-20T18:00:00.000Z",
|
||||
endedAt: "2026-09-20T18:45:00.000Z",
|
||||
};
|
||||
|
||||
function post(workouts: Array<Record<string, unknown>>, auth: RouteAuth) {
|
||||
return callRoute<BatchResult>(POST, {
|
||||
method: "POST",
|
||||
route: ROUTE,
|
||||
body: { workouts },
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
async function storedSources(user: Pick<User, "id">): Promise<string[]> {
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { source: true },
|
||||
});
|
||||
return rows.map((r) => r.source);
|
||||
}
|
||||
|
||||
/** The rate-limit buckets this account's calls have opened, and their counts. */
|
||||
async function buckets(user: Pick<User, "id">) {
|
||||
const rows = await prisma.rateLimit.findMany({
|
||||
where: { key: { contains: user.id } },
|
||||
orderBy: { key: "asc" },
|
||||
select: { key: true, count: true },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("POST /api/workouts/batch: which credentials it admits", () => {
|
||||
it("admits a narrow workouts:write token", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1")],
|
||||
await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.inserted).toBe(1);
|
||||
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(1);
|
||||
});
|
||||
|
||||
it("admits a cookie session", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1", { source: "APPLE_HEALTH" })],
|
||||
await makeSession(user),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.inserted).toBe(1);
|
||||
});
|
||||
|
||||
it("admits a wildcard token", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1", { source: "APPLE_HEALTH" })],
|
||||
await makeToken(user),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.inserted).toBe(1);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1")],
|
||||
await makeToken(user, { permissions: perms }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("auth.scope.insufficient");
|
||||
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch: rate-limit buckets", () => {
|
||||
it("counts a workouts:write token apart from the phone", async () => {
|
||||
const user = await makeUser();
|
||||
const bridge = await makeToken(user, {
|
||||
permissions: [WORKOUTS_WRITE_SCOPE],
|
||||
});
|
||||
const phone = await makeToken(user);
|
||||
|
||||
expect((await post([workout("s-1")], bridge)).status).toBe(200);
|
||||
expect(
|
||||
(await post([workout("s-2", { source: "APPLE_HEALTH" })], phone)).status,
|
||||
).toBe(200);
|
||||
|
||||
const opened = await buckets(user);
|
||||
expect(opened).toHaveLength(2);
|
||||
expect(opened.map((b) => b.count)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("keeps a session and a wildcard token in the same bucket", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
await post(
|
||||
[workout("s-1", { source: "APPLE_HEALTH" })],
|
||||
await makeSession(user),
|
||||
);
|
||||
await post(
|
||||
[workout("s-2", { source: "APPLE_HEALTH", ...EVENING })],
|
||||
await makeToken(user),
|
||||
);
|
||||
|
||||
const opened = await buckets(user);
|
||||
expect(opened).toHaveLength(1);
|
||||
expect(opened[0].count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch: what a scoped caller writes", () => {
|
||||
it("attributes its rows to EXTERNAL", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1"), workout("s-2", EVENING)],
|
||||
await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.inserted).toBe(2);
|
||||
expect(await storedSources(user)).toEqual(["EXTERNAL", "EXTERNAL"]);
|
||||
});
|
||||
|
||||
it("dedups in the EXTERNAL namespace: the phone's id is no collision, its own resend is", async () => {
|
||||
const user = await makeUser();
|
||||
// The phone already stored a different session under the same id.
|
||||
await makeWorkout(user, {
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "s-1",
|
||||
startedAt: new Date("2026-09-18T06:30:00.000Z"),
|
||||
endedAt: new Date("2026-09-18T07:15:00.000Z"),
|
||||
durationSec: 2700,
|
||||
});
|
||||
const bridge = await makeToken(user, {
|
||||
permissions: [WORKOUTS_WRITE_SCOPE],
|
||||
});
|
||||
|
||||
const first = await post([workout("s-1")], bridge);
|
||||
const resend = await post([workout("s-1")], bridge);
|
||||
|
||||
expect(first.data.entries).toEqual([
|
||||
expect.objectContaining({ index: 0, status: "inserted" }),
|
||||
]);
|
||||
expect(resend.data.entries).toEqual([
|
||||
expect.objectContaining({ index: 0, status: "duplicate" }),
|
||||
]);
|
||||
expect(await storedSources(user)).toEqual(["APPLE_HEALTH", "EXTERNAL"]);
|
||||
});
|
||||
|
||||
it.each(["APPLE_HEALTH", "MANUAL"])(
|
||||
"refuses a body naming %s with 422 and writes nothing",
|
||||
async (source) => {
|
||||
const user = await makeUser();
|
||||
|
||||
const res = await post(
|
||||
[workout("s-1"), workout("s-2", { source, ...EVENING })],
|
||||
await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("workout.batch.source_not_permitted");
|
||||
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the source a wildcard caller sends", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
await post(
|
||||
[workout("s-1", { source: "APPLE_HEALTH" })],
|
||||
await makeToken(user),
|
||||
);
|
||||
|
||||
expect(await storedSources(user)).toEqual(["APPLE_HEALTH"]);
|
||||
});
|
||||
|
||||
it("keeps the source a session sends", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
await post([workout("s-1", { source: "MANUAL" })], await makeSession(user));
|
||||
|
||||
expect(await storedSources(user)).toEqual(["MANUAL"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* `GET` and `DELETE /api/workouts/{id}`, against the real database, through
|
||||
* the real API kit, with the personal-records re-derivation queued on a real
|
||||
* pg-boss queue.
|
||||
*
|
||||
* The read, pinned here:
|
||||
* - answers the caller's own workout under the native client's field
|
||||
* names, and 404 for another account's row or a missing one; 401 without
|
||||
* a session, 403 `module.disabled` while the workouts module is off;
|
||||
* - `canonicalId` names the cluster winner (the Apple Health twin when the
|
||||
* Withings sibling was asked for), the winner borrows heart rate and
|
||||
* zones only where it has none of its own, and `previousWorkoutId` is the
|
||||
* canonical session before this one, never a twin of it;
|
||||
* - `dayKey` is the session's day in the account's timezone;
|
||||
* - the route geometry, the stored heart-rate series, zones, splits and
|
||||
* sport context; `compact=1` drops the raw sample and timestamp blobs; a
|
||||
* sealed track reads exactly like a readable one and wins over a readable
|
||||
* column on the same row;
|
||||
* - `aiInsight` serves the stored paragraph while `workoutInsights` is
|
||||
* available, null when none is stored or the paragraph cannot be
|
||||
* decrypted, and never while the capability is unavailable, with `ai`
|
||||
* naming the reason.
|
||||
*
|
||||
* The delete, pinned here:
|
||||
* - removes a workout entered by hand, with the personal records it set,
|
||||
* leaves an audit row, and queues a silent detection pass when a record
|
||||
* went; a legacy record without an external id goes only when it is this
|
||||
* sport's and set at this workout's start;
|
||||
* - a synced workout is 409 `workout.delete.synced_source` and nothing is
|
||||
* touched; another account's row and a missing row are 404; anonymous is
|
||||
* 401 and a narrow token is 403.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import { Prisma, type User, type Workout } from "@/generated/prisma/client";
|
||||
import { DELETE, GET } from "@/app/api/workouts/[id]/route";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { PR_DETECTION_QUEUE } from "@/lib/jobs/pr-detection";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
|
||||
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
|
||||
import {
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
makeWorkout,
|
||||
} from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const ROUTE = "/api/workouts/{id}";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let boss: PgBoss;
|
||||
|
||||
beforeAll(async () => {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
await boss.start();
|
||||
await boss.createQueue(PR_DETECTION_QUEUE);
|
||||
setGlobalBoss(boss);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
setGlobalBoss(null);
|
||||
await boss?.stop({ graceful: false });
|
||||
});
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
/**
|
||||
* Ten days back: the heart-rate curve's pulse-window fallback only reaches
|
||||
* inside the dense-intraday retention horizon, so a fixed calendar day would
|
||||
* age out of it.
|
||||
*/
|
||||
const SESSION_DAY = new Date(Date.now() - 10 * DAY_MS)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const START = new Date(`${SESSION_DAY}T07:00:00Z`);
|
||||
const END = new Date(`${SESSION_DAY}T07:30:00Z`);
|
||||
|
||||
const GEOMETRY = {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.0, 49.0],
|
||||
[11.005, 49.0],
|
||||
[11.01, 49.0],
|
||||
],
|
||||
};
|
||||
const TIMESTAMPS = [
|
||||
`${SESSION_DAY}T07:00:00Z`,
|
||||
`${SESSION_DAY}T07:02:00Z`,
|
||||
`${SESSION_DAY}T07:04:00Z`,
|
||||
];
|
||||
|
||||
interface Detail {
|
||||
id: string;
|
||||
dayKey: string;
|
||||
durationSec: number;
|
||||
distanceM: number | null;
|
||||
activeEnergyKcal: number | null;
|
||||
avgHr: number | null;
|
||||
maxHr: number | null;
|
||||
source: string;
|
||||
metadata: unknown;
|
||||
route: { geometry: unknown; sampleTimestamps: unknown } | null;
|
||||
samples: { sampleCount: number; samples: unknown } | null;
|
||||
hrSeries: { source: string; points: unknown[] } | null;
|
||||
zones: { model: string } | null;
|
||||
splits: unknown;
|
||||
sportContext: unknown;
|
||||
aiInsight: { paragraph: string; generatedAt: string } | null;
|
||||
ai: { available: boolean; reason: string | null; onDeviceAllowed: boolean };
|
||||
canonicalId: string;
|
||||
previousWorkoutId: string | null;
|
||||
}
|
||||
|
||||
function appleRun(
|
||||
user: Pick<User, "id">,
|
||||
overrides: Partial<Omit<Prisma.WorkoutUncheckedCreateInput, "userId">> = {},
|
||||
): Promise<Workout> {
|
||||
return makeWorkout(user, {
|
||||
sportType: "running",
|
||||
startedAt: START,
|
||||
endedAt: END,
|
||||
durationSec: 1800,
|
||||
totalDistanceM: 5000,
|
||||
totalEnergyKcal: 320,
|
||||
avgHeartRate: 145,
|
||||
maxHeartRate: 170,
|
||||
minHeartRate: 110,
|
||||
stepCount: 5800,
|
||||
elevationM: 12.5,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: `ext-${Math.random().toString(36).slice(2)}`,
|
||||
metadata: { hkVersion: "1" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function read(
|
||||
workoutId: string,
|
||||
auth: RouteAuth,
|
||||
query: Record<string, string> = {},
|
||||
) {
|
||||
return callRoute<Detail>(GET, {
|
||||
method: "GET",
|
||||
route: ROUTE,
|
||||
params: { id: workoutId },
|
||||
query,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
/** An account for which `workoutInsights` is available: own key and receipt. */
|
||||
async function makeAiUser(
|
||||
overrides: Partial<Prisma.UserUncheckedCreateInput> = {},
|
||||
) {
|
||||
const user = await makeUser({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-sonnet-4-6",
|
||||
// Presence only: the capability probe never decrypts.
|
||||
aiAnthropicKeyEncrypted: "v1:presence-only",
|
||||
locale: "en",
|
||||
...overrides,
|
||||
});
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: "ai_full",
|
||||
artefact: "test",
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("GET /api/workouts/{id}", () => {
|
||||
it("returns the caller's workout under the native client's field names", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user);
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toMatchObject({
|
||||
id: workout.id,
|
||||
distanceM: 5000,
|
||||
activeEnergyKcal: 320,
|
||||
avgHr: 145,
|
||||
maxHr: 170,
|
||||
canonicalId: workout.id,
|
||||
route: null,
|
||||
// Nothing older on record: absence, not a link to nowhere.
|
||||
previousWorkoutId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("points previousWorkoutId at the canonical session before this one, never at a twin", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user);
|
||||
const fiveDaysBefore = new Date(START.getTime() - 5 * DAY_MS);
|
||||
const previous = await appleRun(user, {
|
||||
startedAt: fiveDaysBefore,
|
||||
endedAt: new Date(fiveDaysBefore.getTime() + 1800_000),
|
||||
});
|
||||
// The same session as seen by a second device, a minute later.
|
||||
await appleRun(user, {
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(fiveDaysBefore.getTime() + 60_000),
|
||||
endedAt: new Date(fiveDaysBefore.getTime() + 1860_000),
|
||||
});
|
||||
const older = new Date(START.getTime() - 8 * DAY_MS);
|
||||
await appleRun(user, {
|
||||
startedAt: older,
|
||||
endedAt: new Date(older.getTime() + 1800_000),
|
||||
});
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.previousWorkoutId).toBe(previous.id);
|
||||
});
|
||||
|
||||
it("dates the session by the account's timezone, not by UTC", async () => {
|
||||
// 20:00 UTC is already the next calendar day in Auckland.
|
||||
const user = await makeUser({ timezone: "Pacific/Auckland" });
|
||||
const workout = await appleRun(user, {
|
||||
startedAt: new Date(`${SESSION_DAY}T20:00:00Z`),
|
||||
endedAt: new Date(`${SESSION_DAY}T20:30:00Z`),
|
||||
});
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const nextDay = new Date(
|
||||
new Date(`${SESSION_DAY}T00:00:00Z`).getTime() + DAY_MS,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
expect(res.data.dayKey).toBe(nextDay);
|
||||
});
|
||||
|
||||
it("fills the Apple winner's missing heart rate and zones from its WHOOP twin, and nothing else", async () => {
|
||||
const user = await makeUser();
|
||||
const apple = await appleRun(user, {
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: { hkVersion: "1", owner: "apple" },
|
||||
});
|
||||
await prisma.workoutRoute.create({
|
||||
data: { workoutId: apple.id, geometry: GEOMETRY },
|
||||
});
|
||||
await appleRun(user, {
|
||||
source: "WHOOP",
|
||||
startedAt: new Date(START.getTime() + 30_000),
|
||||
endedAt: new Date(END.getTime() + 30_000),
|
||||
avgHeartRate: 148,
|
||||
maxHeartRate: 181,
|
||||
metadata: {
|
||||
zoneDurations: { zone_one_milli: 60_000, zone_two_milli: 300_000 },
|
||||
whoopWorkoutStrain: 12.3,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await read(apple.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toMatchObject({
|
||||
id: apple.id,
|
||||
source: "APPLE_HEALTH",
|
||||
canonicalId: apple.id,
|
||||
avgHr: 148,
|
||||
maxHr: 181,
|
||||
route: { geometry: GEOMETRY },
|
||||
metadata: { hkVersion: "1", owner: "apple" },
|
||||
});
|
||||
expect(res.data.metadata).not.toHaveProperty("whoopWorkoutStrain");
|
||||
expect(res.data.metadata).not.toHaveProperty("zoneDurations");
|
||||
expect(res.data.zones?.model).toBe("whoop");
|
||||
expect(res.data.hrSeries).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the winner's own heart rate when its twin carries different values", async () => {
|
||||
const user = await makeUser();
|
||||
const apple = await appleRun(user);
|
||||
await appleRun(user, {
|
||||
source: "WHOOP",
|
||||
startedAt: new Date(START.getTime() + 30_000),
|
||||
endedAt: new Date(END.getTime() + 30_000),
|
||||
avgHeartRate: 155,
|
||||
maxHeartRate: 190,
|
||||
metadata: Prisma.JsonNull,
|
||||
});
|
||||
|
||||
const res = await read(apple.id, await makeSession(user));
|
||||
|
||||
expect(res.data.avgHr).toBe(145);
|
||||
expect(res.data.maxHr).toBe(170);
|
||||
});
|
||||
|
||||
it("returns 401 without a session", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user);
|
||||
|
||||
const res = await read(workout.id, anonymous);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.meta?.errorCode).toBe("auth.missing");
|
||||
});
|
||||
|
||||
it("returns 403 module.disabled while the workouts module is off", async () => {
|
||||
const user = await makeUser({ modulePreferencesJson: { workouts: false } });
|
||||
const workout = await appleRun(user);
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("module.disabled");
|
||||
expect(res.meta?.module).toBe("workouts");
|
||||
});
|
||||
|
||||
it("returns 404 for another account's workout", async () => {
|
||||
const owner = await makeUser();
|
||||
const workout = await appleRun(owner);
|
||||
|
||||
const res = await read(workout.id, await makeSession(await makeUser()));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.data).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when no such workout exists", async () => {
|
||||
const res = await read(
|
||||
"no-such-workout",
|
||||
await makeSession(await makeUser()),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("resolves canonicalId to the Apple Health twin when the Withings sibling is asked for", async () => {
|
||||
const user = await makeUser();
|
||||
const apple = await appleRun(user);
|
||||
const withings = await appleRun(user, { source: "WITHINGS" });
|
||||
|
||||
const res = await read(withings.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.id).toBe(withings.id);
|
||||
expect(res.data.canonicalId).toBe(apple.id);
|
||||
});
|
||||
|
||||
it("adds the stored series, device zones, splits and sport context, and drops the raw blobs under compact=1", async () => {
|
||||
const user = await makeUser();
|
||||
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
|
||||
t: new Date(START.getTime() + i * 45_000).toISOString(),
|
||||
hr: 140 + (i % 6),
|
||||
}));
|
||||
const workout = await appleRun(user, {
|
||||
// WHOOP device zones win, so zones render without a 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.workoutRoute.create({
|
||||
data: {
|
||||
workoutId: workout.id,
|
||||
geometry: GEOMETRY,
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
},
|
||||
});
|
||||
await prisma.workoutSamples.create({
|
||||
data: {
|
||||
workoutId: workout.id,
|
||||
samples: storedSamples,
|
||||
sampleCount: storedSamples.length,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await read(workout.id, await makeSession(user), {
|
||||
compact: "1",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.hrSeries?.source).toBe("workout_series");
|
||||
expect(res.data.hrSeries?.points.length).toBeGreaterThanOrEqual(2);
|
||||
expect(res.data.zones?.model).toBe("whoop");
|
||||
expect(res.data).toHaveProperty("splits");
|
||||
expect(res.data).toHaveProperty("sportContext");
|
||||
expect(res.data.aiInsight).toBeNull();
|
||||
expect(res.data.samples).toEqual({ sampleCount: 40, samples: null });
|
||||
expect(res.data.route?.geometry).toEqual(GEOMETRY);
|
||||
expect(res.data.route?.sampleTimestamps).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the route geometry and its timestamps without compact", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user);
|
||||
await prisma.workoutRoute.create({
|
||||
data: {
|
||||
workoutId: workout.id,
|
||||
geometry: GEOMETRY,
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.data.route).toEqual({
|
||||
geometry: GEOMETRY,
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a sealed track in the same shape as a readable one, splits included", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const readable = await appleRun(user);
|
||||
await prisma.workoutRoute.create({
|
||||
data: {
|
||||
workoutId: readable.id,
|
||||
geometry: GEOMETRY,
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
},
|
||||
});
|
||||
const otherDay = new Date(START.getTime() - 2 * DAY_MS);
|
||||
const sealed = await appleRun(user, {
|
||||
startedAt: otherDay,
|
||||
endedAt: new Date(otherDay.getTime() + 1800_000),
|
||||
});
|
||||
await prisma.workoutRoute.create({
|
||||
data: {
|
||||
workoutId: sealed.id,
|
||||
geometryEncrypted: encryptRouteGeometry(GEOMETRY),
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
},
|
||||
});
|
||||
|
||||
const legacy = await read(readable.id, session);
|
||||
const fromCipher = await read(sealed.id, session);
|
||||
|
||||
expect(fromCipher.data.route).toEqual(legacy.data.route);
|
||||
expect(fromCipher.data.route?.geometry).toEqual(GEOMETRY);
|
||||
expect(fromCipher.data.splits).toEqual(legacy.data.splits);
|
||||
});
|
||||
|
||||
it("prefers the sealed track over a readable column on the same row", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user);
|
||||
await prisma.workoutRoute.create({
|
||||
data: {
|
||||
workoutId: workout.id,
|
||||
geometry: { type: "LineString", coordinates: [[0, 0]] },
|
||||
geometryEncrypted: encryptRouteGeometry(GEOMETRY),
|
||||
sampleTimestamps: TIMESTAMPS,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await read(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.data.route?.geometry).toEqual(GEOMETRY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/workouts/{id}: aiInsight", () => {
|
||||
async function withInsight(
|
||||
user: Pick<User, "id">,
|
||||
paragraphEncrypted: Uint8Array<ArrayBuffer>,
|
||||
generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`),
|
||||
): Promise<Workout> {
|
||||
const workout = await appleRun(user);
|
||||
await prisma.workoutInsight.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
workoutId: workout.id,
|
||||
paragraphEncrypted,
|
||||
inputHash: "hash",
|
||||
promptVersion: "v1",
|
||||
providerType: "anthropic",
|
||||
locale: "en",
|
||||
generatedAt,
|
||||
},
|
||||
});
|
||||
return workout;
|
||||
}
|
||||
|
||||
function sealed(text: string): Uint8Array<ArrayBuffer> {
|
||||
return encryptToBytes(text);
|
||||
}
|
||||
|
||||
it("serves null when the workout has no stored paragraph", async () => {
|
||||
const user = await makeAiUser();
|
||||
const workout = await appleRun(user);
|
||||
|
||||
const res = await read(workout.id, await makeSession(user), {
|
||||
compact: "1",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.ai.available).toBe(true);
|
||||
expect(res.data.aiInsight).toBeNull();
|
||||
});
|
||||
|
||||
it("serves the decrypted paragraph while workoutInsights is available", async () => {
|
||||
const user = await makeAiUser();
|
||||
const generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`);
|
||||
const workout = await withInsight(
|
||||
user,
|
||||
sealed("A steady, aerobic-leaning run."),
|
||||
generatedAt,
|
||||
);
|
||||
|
||||
const res = await read(workout.id, await makeSession(user), {
|
||||
compact: "1",
|
||||
});
|
||||
|
||||
expect(res.data.aiInsight).toEqual({
|
||||
paragraph: "A steady, aerobic-leaning run.",
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
});
|
||||
expect(res.data.ai).toEqual({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"operator_disabled",
|
||||
async () => {
|
||||
await prisma.appSettings.create({
|
||||
data: { id: "singleton", assistantEnabled: false },
|
||||
});
|
||||
return makeAiUser();
|
||||
},
|
||||
],
|
||||
[
|
||||
"user_disabled",
|
||||
() => makeAiUser({ modulePreferencesJson: { insights: false } }),
|
||||
],
|
||||
[
|
||||
"consent_required",
|
||||
async () => {
|
||||
await prisma.appSettings.create({
|
||||
data: {
|
||||
id: "singleton",
|
||||
adminAiKeyEncrypted: "v1:presence-only",
|
||||
adminAiModel: "gpt-4o",
|
||||
},
|
||||
});
|
||||
return makeUser({ locale: "en" });
|
||||
},
|
||||
],
|
||||
["no_provider", () => makeUser({ locale: "en" })],
|
||||
] as const)(
|
||||
"does not serve the stored paragraph while workoutInsights is %s",
|
||||
async (reason, arrange) => {
|
||||
const user = await arrange();
|
||||
const workout = await withInsight(user, sealed("Model-written."));
|
||||
|
||||
const res = await read(workout.id, await makeSession(user), {
|
||||
compact: "1",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.aiInsight).toBeNull();
|
||||
expect(res.data.ai.available).toBe(false);
|
||||
expect(res.data.ai.reason).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it("degrades to no paragraph rather than failing the read on an undecryptable row", async () => {
|
||||
const user = await makeAiUser();
|
||||
const workout = await withInsight(user, new Uint8Array([0, 1, 2, 3]));
|
||||
|
||||
const res = await read(workout.id, await makeSession(user), {
|
||||
compact: "1",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.aiInsight).toBeNull();
|
||||
expect(res.data.id).toBe(workout.id);
|
||||
expect(res.data.durationSec).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/workouts/{id}", () => {
|
||||
async function del(workoutId: string, auth: RouteAuth) {
|
||||
return callRoute<{ deleted: true }>(DELETE, {
|
||||
method: "DELETE",
|
||||
route: ROUTE,
|
||||
params: { id: workoutId },
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
function manualRun(
|
||||
user: Pick<User, "id">,
|
||||
overrides: Partial<Omit<Prisma.WorkoutUncheckedCreateInput, "userId">> = {},
|
||||
) {
|
||||
return appleRun(user, {
|
||||
source: "MANUAL",
|
||||
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function record(
|
||||
user: Pick<User, "id">,
|
||||
overrides: Partial<
|
||||
Omit<Prisma.PersonalRecordUncheckedCreateInput, "userId">
|
||||
> = {},
|
||||
) {
|
||||
return prisma.personalRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
metricSlot: "longest_run_duration",
|
||||
direction: "MAX",
|
||||
value: 1800,
|
||||
unit: "s",
|
||||
achievedAt: START,
|
||||
source: "MANUAL",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function detectionJobs(userId: string) {
|
||||
return prisma.$queryRaw<
|
||||
Array<{ data: { userId: string; silent: boolean } }>
|
||||
>`
|
||||
SELECT data FROM pgboss.job
|
||||
WHERE name = ${PR_DETECTION_QUEUE} AND data->>'userId' = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
it("removes a workout entered by hand, audits it, and the next read is 404", async () => {
|
||||
const user = await makeUser();
|
||||
const session = await makeSession(user);
|
||||
const workout = await manualRun(user);
|
||||
|
||||
const res = await del(workout.id, session);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { deleted: true }, error: null });
|
||||
expect(
|
||||
await prisma.workout.findUnique({ where: { id: workout.id } }),
|
||||
).toBeNull();
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId: user.id, action: "workout.delete" },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0].details ?? "null")).toMatchObject({
|
||||
workoutId: workout.id,
|
||||
sportType: "running",
|
||||
personalRecordsRemoved: 0,
|
||||
});
|
||||
expect((await read(workout.id, session)).status).toBe(404);
|
||||
// Nothing was removed, so nothing needs re-deriving.
|
||||
expect(await detectionJobs(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the personal records the workout set with it and queues a silent re-derivation", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await manualRun(user);
|
||||
const set = await record(user, { externalId: workout.externalId });
|
||||
const unrelated = await record(user, {
|
||||
externalId: "manual:another-workout",
|
||||
achievedAt: new Date(START.getTime() - DAY_MS),
|
||||
});
|
||||
|
||||
const res = await del(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
await prisma.personalRecord.findUnique({ where: { id: set.id } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await prisma.personalRecord.findUnique({ where: { id: unrelated.id } }),
|
||||
).not.toBeNull();
|
||||
const audit = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { userId: user.id, action: "workout.delete" },
|
||||
});
|
||||
expect(JSON.parse(audit.details ?? "null")).toMatchObject({
|
||||
personalRecordsRemoved: 1,
|
||||
});
|
||||
const jobs = await detectionJobs(user.id);
|
||||
expect(jobs.map((j) => j.data.silent)).toEqual([true]);
|
||||
});
|
||||
|
||||
it("removes a legacy record without an external id only when it is this sport's and set at this start", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await manualRun(user, { externalId: null });
|
||||
const running = await record(user, { externalId: null });
|
||||
// A cycling record set at the same instant is not this workout's.
|
||||
const cycling = await record(user, {
|
||||
externalId: null,
|
||||
metricSlot: "longest_cycle_duration",
|
||||
});
|
||||
const otherStart = await record(user, {
|
||||
externalId: null,
|
||||
metricSlot: "longest_distance_run",
|
||||
achievedAt: new Date(START.getTime() - DAY_MS),
|
||||
});
|
||||
|
||||
const res = await del(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const left = await prisma.personalRecord.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(left.map((r) => r.id).sort()).toEqual(
|
||||
[cycling.id, otherStart.id].sort(),
|
||||
);
|
||||
expect(left.map((r) => r.id)).not.toContain(running.id);
|
||||
});
|
||||
|
||||
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"] as const)(
|
||||
"refuses a %s workout with 409 and touches nothing",
|
||||
async (source) => {
|
||||
const user = await makeUser();
|
||||
const workout = await appleRun(user, { source });
|
||||
const set = await record(user, {
|
||||
source,
|
||||
externalId: workout.externalId,
|
||||
});
|
||||
|
||||
const res = await del(workout.id, await makeSession(user));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.data).toBeNull();
|
||||
expect(res.meta?.errorCode).toBe("workout.delete.synced_source");
|
||||
expect(
|
||||
await prisma.workout.findUnique({ where: { id: workout.id } }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await prisma.personalRecord.findUnique({ where: { id: set.id } }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId: user.id, action: "workout.delete" },
|
||||
}),
|
||||
).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("answers 404 for another account's workout and leaves it in place", async () => {
|
||||
const owner = await makeUser();
|
||||
const workout = await manualRun(owner);
|
||||
|
||||
const res = await del(workout.id, await makeSession(await makeUser()));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(
|
||||
await prisma.workout.findUnique({ where: { id: workout.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("answers 404 when there is no such workout", async () => {
|
||||
const res = await del("missing", await makeSession(await makeUser()));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("answers 401 without a session", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await manualRun(user);
|
||||
|
||||
const res = await del(workout.id, anonymous);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(
|
||||
await prisma.workout.findUnique({ where: { id: workout.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a narrow token with 403 and leaves the workout in place", async () => {
|
||||
const user = await makeUser();
|
||||
const workout = await manualRun(user);
|
||||
|
||||
const res = await del(
|
||||
workout.id,
|
||||
await makeToken(user, { permissions: ["workouts:write"] }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.meta?.errorCode).toBe("auth.scope.insufficient");
|
||||
expect(
|
||||
await prisma.workout.findUnique({ where: { id: workout.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user