mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge branch 'fix/v14311-settings' into release/v1.43.11
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/nl.json # messages/pl.json
This commit is contained in:
commit
eabfeaef08
74 files changed
+2545
-436
No files matched your search
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Removing one saved AI key, against a real account row.
|
||||
*
|
||||
* The settings forms send `{ <provider>Key: null }` to `PATCH
|
||||
* /api/user/ai-provider` to take a stored key back. This pins the route's
|
||||
* half of that against Postgres: the one ciphertext column is cleared, the
|
||||
* other keys, the provider choice and the model are left as they were, and
|
||||
* the read the form seeds from reports the key gone.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seedAccountWithKeys(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "ai-key-removal",
|
||||
email: "ai-key-removal@example.test",
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: "gpt-4o-mini",
|
||||
aiOpenaiKeyEncrypted: encrypt("sk-test-openai-0001"),
|
||||
aiAnthropicKeyEncrypted: encrypt("sk-ant-test-0002"),
|
||||
aiLocalKeyEncrypted: encrypt("local-test-0003"),
|
||||
aiCompatKeyEncrypted: encrypt("compat-test-0004"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function patch(body: unknown): Promise<Response> {
|
||||
const { PATCH } = await import("@/app/api/user/ai-provider/route");
|
||||
return PATCH(
|
||||
new NextRequest("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function readProvider(): Promise<Record<string, unknown>> {
|
||||
const { GET } = await import("@/app/api/user/ai-provider/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: Record<string, unknown> }).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider — removing one saved key", () => {
|
||||
it("clears that key's column and leaves every other setting", async () => {
|
||||
const userId = await seedAccountWithKeys();
|
||||
|
||||
const res = await patch({ openaiKey: null });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
expect(row.aiOpenaiKeyEncrypted).toBeNull();
|
||||
expect(row.aiAnthropicKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiLocalKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiCompatKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiProvider).toBe("OPENAI");
|
||||
expect(row.aiModel).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("the read the form seeds from reports the key gone", async () => {
|
||||
await seedAccountWithKeys();
|
||||
expect(await readProvider()).toMatchObject({
|
||||
hasOpenaiKey: true,
|
||||
hasAnthropicKey: true,
|
||||
});
|
||||
|
||||
await patch({ anthropicKey: null });
|
||||
|
||||
expect(await readProvider()).toMatchObject({
|
||||
hasOpenaiKey: true,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: true,
|
||||
hasCompatKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
for (const field of ["localKey", "compatKey"] as const) {
|
||||
it(`clears ${field} on its own`, async () => {
|
||||
await seedAccountWithKeys();
|
||||
await patch({ [field]: null });
|
||||
const data = await readProvider();
|
||||
expect(data[field === "localKey" ? "hasLocalKey" : "hasCompatKey"]).toBe(
|
||||
false,
|
||||
);
|
||||
expect(data.hasOpenaiKey).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -133,4 +133,29 @@ describe("emergency profile route — the write lands in the row", () => {
|
||||
});
|
||||
expect(row.emergencyContactsEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("a select sent as null goes back to not recorded and leaves the others", async () => {
|
||||
// The card sends a cleared select as `null` (and leaves an untouched one
|
||||
// out). Until it did, a recorded blood type could be changed but never
|
||||
// removed.
|
||||
await loginAs(USER_ID);
|
||||
const { GET, PATCH } = await import("@/app/api/anamnesis/emergency/route");
|
||||
|
||||
await PATCH(patch({ bloodType: "A_POS", organDonor: "YES" }));
|
||||
const cleared = await PATCH(patch({ bloodType: null }));
|
||||
expect(cleared.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
select: { emergencyBloodType: true, organDonorStatus: true },
|
||||
});
|
||||
expect(row.emergencyBloodType).toBeNull();
|
||||
expect(row.organDonorStatus).toBe("YES");
|
||||
|
||||
const body = (await (await GET()).json()) as {
|
||||
data: { bloodType: string | null; organDonor: string | null };
|
||||
};
|
||||
expect(body.data.bloodType).toBeNull();
|
||||
expect(body.data.organDonor).toBe("YES");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The `/notifications` matrix and delivery give one answer for an event with
|
||||
* no stored preference row, against a real account row.
|
||||
*
|
||||
* The mood reminder is the event this exists for: it has no switch of its own
|
||||
* at the channel layer and follows the reminder card. Delivery read the card,
|
||||
* the matrix read a constant table, and an account receiving the reminder saw
|
||||
* it switched off on every channel. The preferences read now publishes
|
||||
* `eventDefaults` from the resolver delivery asks; this suite flips the real
|
||||
* column and checks the published switch and the actual send move together.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const ntfySendMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/ntfy", () => ({
|
||||
sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USERNAME = "event-defaults";
|
||||
|
||||
async function seedAccount(moodReminderEnabled: boolean): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: USERNAME,
|
||||
email: `${USERNAME}@example.test`,
|
||||
moodReminderEnabled,
|
||||
},
|
||||
});
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "NTFY",
|
||||
enabled: true,
|
||||
config: encrypt(
|
||||
JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }),
|
||||
),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function readDefaults(): Promise<Record<string, boolean>> {
|
||||
const { GET } = await import("@/app/api/notifications/preferences/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { preferences: unknown[]; eventDefaults: Record<string, boolean> };
|
||||
};
|
||||
// The case is about the no-row path; a stored row would decide instead.
|
||||
expect(body.data.preferences).toEqual([]);
|
||||
return body.data.eventDefaults;
|
||||
}
|
||||
|
||||
async function dispatchMood(userId: string) {
|
||||
const { dispatchNotification } =
|
||||
await import("@/lib/notifications/dispatcher");
|
||||
return dispatchNotification({
|
||||
userId,
|
||||
eventType: "MOOD_REMINDER",
|
||||
title: "How are you today?",
|
||||
message: "Log your mood",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
ntfySendMock.mockResolvedValue({ ok: true });
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("event defaults: the matrix shows what delivery does", () => {
|
||||
it("reminder card on: the matrix shows ON and the reminder is sent", async () => {
|
||||
const userId = await seedAccount(true);
|
||||
|
||||
expect((await readDefaults()).MOOD_REMINDER).toBe(true);
|
||||
|
||||
await dispatchMood(userId);
|
||||
expect(ntfySendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reminder card off: the matrix shows OFF and nothing is sent", async () => {
|
||||
const userId = await seedAccount(false);
|
||||
|
||||
expect((await readDefaults()).MOOD_REMINDER).toBe(false);
|
||||
|
||||
await dispatchMood(userId);
|
||||
expect(ntfySendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes a default for every event the server knows", async () => {
|
||||
await seedAccount(false);
|
||||
const { EVENT_TYPES } = await import("@/lib/notifications/types");
|
||||
expect(Object.keys(await readDefaults()).sort()).toEqual(
|
||||
[...EVENT_TYPES].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The display name round-trips through the profile save and the account
|
||||
* payload, against a real account row.
|
||||
*
|
||||
* The profile card seeds its display-name field from `GET /api/auth/me` and
|
||||
* saves it through `PUT /api/auth/profile`. Until now `/me` did not publish
|
||||
* the name at all, so the two ends could not meet; this pins the pipe: a save
|
||||
* lands in the column and comes back on the payload, and an emptied field
|
||||
* clears it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seed(displayName: string | null): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "display-name",
|
||||
email: "display-name@example.test",
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function save(body: Record<string, unknown>): Promise<Response> {
|
||||
const { PUT } = await import("@/app/api/auth/profile/route");
|
||||
return PUT(
|
||||
new NextRequest("http://localhost/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function me(): Promise<{ displayName?: string | null }> {
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { displayName?: string | null } }).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("display name: profile save and account payload", () => {
|
||||
it("publishes the stored name on /api/auth/me", async () => {
|
||||
await seed("Sam");
|
||||
expect((await me()).displayName).toBe("Sam");
|
||||
});
|
||||
|
||||
it("a save lands in the column and comes back on the payload", async () => {
|
||||
const userId = await seed("Sam");
|
||||
|
||||
const res = await save({ displayName: " Alex " });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { displayName: true },
|
||||
});
|
||||
expect(row.displayName).toBe("Alex");
|
||||
expect((await me()).displayName).toBe("Alex");
|
||||
});
|
||||
|
||||
it("an emptied field clears the name", async () => {
|
||||
await seed("Sam");
|
||||
expect((await save({ displayName: null })).status).toBe(200);
|
||||
expect((await me()).displayName).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user