mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
reconcile: fold in the outstanding hardening and data-integrity fixes
Merge the eight reviewed audit fixes that were cut a week ago and never landed. Main has moved on since, so each fix was re-checked against the current tree and each of the five conflicts resolved by understanding both sides. Kept, still open on main: - Proxy early exits now carry the transport security header subset via a shared helper. Reconciled with main's /api/health worker-only carve-out. - Batch measurement ingest bounds the entry instant so a future-dated row can no longer win "latest reading" or poison the rollup buckets. - Web Push dials through the pinned safeFetch dispatcher with requirePublicHost, closing the last user-controlled-host egress. - Mood bulk upsert and its tag/factor links run in one transaction, so a rejected rating rolls the mood row back instead of leaving a tagless row. - Full backup carries NutrientIntakeDay with its unit and source. Folded into main's portable-export / disaster-recovery split. - Live daily-series fallback restores the min/max spread band the tiered read already emitted. - Coach stream aborts its in-flight reader on unmount and resets send state on a conversation switch. - Manual water writes share the day-bound helper, nutrient add clamps after the increment, and the thiamin cap drops to a real supplement ceiling. - Compliance point window widens symmetrically instead of wrapping overnight. - Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx body as a handled transient error. - WebAuthn relying-party fallback to localhost is gated to development. - Chart-token strip list is derived from the allowlist and pinned by a test. - Regression tests pin the snapshot-cache tenant key, the GlitchTip path-secret redaction and the Withings credentials delete. Superseded by main, tests re-pointed at the current shape: - The intake CSV import was rewritten on main to run through a background job with a single-active-job admission gate. The future-date bound is re-applied at request validation and the per-user limiter is kept for parity with the bulk twin; the old synchronous-write test is replaced.
This commit is contained in:
commit
b1e36d9fb0
51 files changed
+2066
-228
No files matched your search
@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -70,6 +71,9 @@ function mkReq(url: string): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// `resetAllMocks` clears the inline default, and the full-backup payload
|
||||
// builder reads this unconditionally.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), count: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: { findUnique: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -116,6 +116,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* `POST /api/measurements/batch` — entry-instant plausibility bound.
|
||||
*
|
||||
* The single-entry twin has applied `validateEntryInstant` to `measuredAt`
|
||||
* since v1.17 W1b; the 500-entry batch path did not, so a future-dated row
|
||||
* landed and permanently won "latest reading" on every dashboard while
|
||||
* poisoning the rollup buckets and the canonical picker.
|
||||
*
|
||||
* The past floor deliberately stays at 1900: a historical Apple Health
|
||||
* backfill is a legitimate shape on this route. Only the future side is a bug.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
return (fn as (tx: unknown) => unknown)(prisma);
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Main routes every ingested row through `reconcileExternalMeasurement`; the
|
||||
// plausibility guard runs BEFORE that call, so a valid entry only needs the
|
||||
// reconcile to report a non-skip status for the assertion to hold.
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: vi.fn(
|
||||
async (_tx: unknown, input: Record<string, unknown>) => ({
|
||||
status: "inserted",
|
||||
row: { id: "row-1", ...input },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
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/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: 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 "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
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(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** A step entry anchored on an offset from now, so the test never date-bombs. */
|
||||
function stepEntryAt(externalId: string, startMs: number, endMs = startMs) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value: 1200,
|
||||
unit: "count",
|
||||
startDate: new Date(startMs).toISOString(),
|
||||
endDate: new Date(endMs).toISOString(),
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function statusesFor(entries: unknown[]) {
|
||||
const res = await POST(makeRequest({ entries }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
};
|
||||
};
|
||||
return body.data.entries;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// clearAllMocks (not resetAllMocks): the fire-and-forget post-processing
|
||||
// mocks above carry `.mockResolvedValue` implementations that the route
|
||||
// chains `.catch` onto; resetAllMocks would wipe those to bare `undefined`.
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
|
||||
{ id: "row-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — entry-instant plausibility", () => {
|
||||
it("skips a far-future entry instead of persisting it", async () => {
|
||||
const results = await statusesFor([
|
||||
stepEntryAt("ext-future", Date.now() + 365 * DAY_MS),
|
||||
]);
|
||||
expect(results[0]).toMatchObject({
|
||||
index: 0,
|
||||
status: "skipped",
|
||||
reason: "implausible_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips an entry predating the 1900 floor", async () => {
|
||||
const results = await statusesFor([
|
||||
stepEntryAt("ext-ancient", Date.UTC(1899, 11, 31)),
|
||||
]);
|
||||
expect(results[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "implausible_timestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a historical backfill entry — the past floor stays at 1900", async () => {
|
||||
const results = await statusesFor([
|
||||
stepEntryAt("ext-old", Date.now() - 8 * 365 * DAY_MS),
|
||||
]);
|
||||
expect(results[0]!.status).not.toBe("skipped");
|
||||
});
|
||||
|
||||
it("tolerates a client clock a couple of minutes fast", async () => {
|
||||
const results = await statusesFor([
|
||||
stepEntryAt("ext-skew", Date.now() + 2 * 60 * 1000),
|
||||
]);
|
||||
expect(results[0]!.status).not.toBe("skipped");
|
||||
});
|
||||
|
||||
it("skips only the offending entry and keeps the rest of the batch", async () => {
|
||||
const results = await statusesFor([
|
||||
stepEntryAt("ext-ok", Date.now() - DAY_MS),
|
||||
stepEntryAt("ext-bad", Date.now() + 365 * DAY_MS),
|
||||
]);
|
||||
expect(results[0]!.status).not.toBe("skipped");
|
||||
expect(results[1]).toMatchObject({ reason: "implausible_timestamp" });
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -294,6 +295,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Time-plausibility guard, the bound the single-entry twin already applies
|
||||
// through `validateEntryInstant` on `createMeasurementSchema.measuredAt`.
|
||||
// Without it a future-dated row permanently won "latest reading" on every
|
||||
// dashboard and poisoned the rollup buckets and the canonical picker. The
|
||||
// past floor stays at 1900 (no `maxAgeMs`) because a historical Apple
|
||||
// Health backfill is a legitimate, expected shape on this route; only the
|
||||
// future side is a bug. Skipped per entry, like the range guard above.
|
||||
if (!isPlausibleEntryInstant(mapped.takenAt)) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: "implausible_timestamp",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
|
||||
// row is the 10-minute-average PULSE bucket; it rides the generic
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(),
|
||||
@@ -57,6 +58,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
|
||||
@@ -86,6 +88,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({
|
||||
send: vi.fn().mockResolvedValue("boss-1"),
|
||||
} as never);
|
||||
@@ -344,3 +351,64 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/import — future bound and limiter", () => {
|
||||
// The `\d{4}-\d{2}-\d{2}` regex has no upper bound and the calendar-validity
|
||||
// refine accepts any real date, future ones included. A future-dated row
|
||||
// would enqueue a "taken" intake ahead of now, which the worker then feeds
|
||||
// into compliance and the dose-history ledger. The single and bulk intake
|
||||
// twins bound this through `boundedTakenAtSchema`; reject at validation here.
|
||||
it("rejects a future-dated row with 422 before creating a job", async () => {
|
||||
const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
const datum = future.toISOString().slice(0, 10);
|
||||
const res = await POST(
|
||||
postReq([{ datum, uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still admits a historical row — the past side stays unbounded", async () => {
|
||||
const res = await POST(
|
||||
postReq([{ datum: "2019-03-04", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// This was the one intake write path with no limiter; the heavy loop now runs
|
||||
// in the worker, but the admission transaction still does per-request DB work.
|
||||
it("refuses with 429 once the import limiter trips", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
postReq([{ datum: "2026-01-01", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buckets the limiter per user", async () => {
|
||||
await POST(
|
||||
postReq([{ datum: "2026-01-01", uhrzeit: "07:00:00" }]),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"medications:intake:import:user-1",
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
const importEntrySchema = z.object({
|
||||
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -43,16 +45,36 @@ function isValidImportTimestamp(datum: string, uhrzeit: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const validatedImportEntrySchema = importEntrySchema.refine(
|
||||
({ datum, uhrzeit }) => isValidImportTimestamp(datum, uhrzeit),
|
||||
{ message: "Invalid date or time" },
|
||||
);
|
||||
const validatedImportEntrySchema = importEntrySchema
|
||||
.refine(({ datum, uhrzeit }) => isValidImportTimestamp(datum, uhrzeit), {
|
||||
message: "Invalid date or time",
|
||||
})
|
||||
// The `\d{4}-\d{2}-\d{2}` regex above has no upper bound, so a `2999-12-31`
|
||||
// row would enqueue a future "taken" intake that then feeds compliance and
|
||||
// the dose-history ledger through the worker. The single and bulk intake
|
||||
// twins bound this through `boundedTakenAtSchema`; this path did not. The
|
||||
// past side stays open (no `maxAgeMs`, only the 1900 floor) because
|
||||
// importing years of history is the whole point of this route, so only the
|
||||
// future bound applies.
|
||||
.refine(
|
||||
({ datum, uhrzeit }) =>
|
||||
isPlausibleEntryInstant(new Date(`${datum}T${uhrzeit}`)),
|
||||
{ message: "Timestamp is not a plausible capture time" },
|
||||
);
|
||||
|
||||
const validatedImportSchema = z
|
||||
.array(validatedImportEntrySchema)
|
||||
.min(1)
|
||||
.max(1000);
|
||||
|
||||
// Mirrors the bulk intake twin's budget. The heavy find + create +
|
||||
// inventory-consume loop now runs in the background worker, but the admission
|
||||
// transaction still does per-request DB work (a `FOR UPDATE` lock plus a stale
|
||||
// -job sweep), so the same limiter the bulk twin carries keeps a rapid-fire
|
||||
// caller from hammering it.
|
||||
const IMPORT_RATE_LIMIT_MAX = 60;
|
||||
const IMPORT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
@@ -65,6 +87,15 @@ export const POST = apiHandler(
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`medications:intake:import:${user.id}`,
|
||||
IMPORT_RATE_LIMIT_MAX,
|
||||
IMPORT_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many import submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -13,13 +13,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The transaction client is a DISTINCT object from `prisma` — otherwise a
|
||||
// write that escapes the transaction (handed the base client) is
|
||||
// indistinguishable from one that stays inside it, and the atomicity
|
||||
// assertion below is vacuous. It SHARES the model mocks so every existing
|
||||
// per-model assertion in this file keeps working unchanged.
|
||||
const { moodEntryMock, txClient } = vi.hoisted(() => {
|
||||
const moodEntryMock = { findMany: vi.fn(), upsert: vi.fn() };
|
||||
return {
|
||||
moodEntryMock,
|
||||
txClient: { __brand: "transaction-client", moodEntry: moodEntryMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
moodEntry: moodEntryMock,
|
||||
auditLog: { create: 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)(txClient);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -247,7 +264,9 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
"entry-1",
|
||||
"user-1",
|
||||
["movies", "gaming"],
|
||||
prisma,
|
||||
// The TRANSACTION client, not the base client — the write must not
|
||||
// escape the transaction the mood row is being upserted in.
|
||||
txClient,
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -298,7 +317,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
"entry-1",
|
||||
"user-1",
|
||||
["movies"],
|
||||
prisma,
|
||||
txClient,
|
||||
[{ key: "factor_work", rating: 4 }],
|
||||
);
|
||||
});
|
||||
@@ -530,3 +549,69 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — upsert + tag links are atomic", () => {
|
||||
// `createTagLinks` validates `ratedFactors` AFTER the mood row is written.
|
||||
// Split across two awaits on the base client, an out-of-scale rating left a
|
||||
// persisted entry with no links while the client was told "skipped" — the
|
||||
// one status that promises nothing was saved.
|
||||
it("runs the upsert and the tag-link write inside one transaction", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "mood-1",
|
||||
} as never);
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
tagKeys: ["sleep"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
// The link write must receive the transaction client, not the base client
|
||||
// — handing it `prisma` would silently escape the transaction.
|
||||
const txArg = vi.mocked(createTagLinks).mock.calls[0]?.[3];
|
||||
const handedToTransaction = vi.mocked(prisma.$transaction).mock.calls
|
||||
.length;
|
||||
expect(handedToTransaction).toBeGreaterThan(0);
|
||||
expect(txArg).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not report a skipped entry as inserted when the factor write throws", async () => {
|
||||
const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links");
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "mood-1",
|
||||
} as never);
|
||||
vi.mocked(createTagLinks).mockRejectedValueOnce(
|
||||
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { inserted: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(json.data.entries[0]!.status).toBe("skipped");
|
||||
expect(json.data.inserted).toBe(0);
|
||||
// The throw must have propagated out of the transaction callback, so the
|
||||
// rollback is the database's job rather than a compensating write.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -300,63 +300,73 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await prisma.moodEntry.upsert({
|
||||
where: probeWhere,
|
||||
create: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the mood + tags + note triple. The
|
||||
// iOS client only re-posts an existing entry when it has
|
||||
// new data; the server trusts that decision. When the dedup
|
||||
// key is `externalId`, also refresh `date` / `moodLoggedAt`
|
||||
// so a re-zoned re-import lands the corrected wall-clock on
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
...(entry.externalId
|
||||
? { date, moodLoggedAt: entry.moodLoggedAt }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// The upsert and the tag/factor links run in ONE transaction, matching
|
||||
// the single-entry POST path. Split apart, an out-of-scale
|
||||
// `ratedFactors` value (validated inside `createTagLinks`, i.e. AFTER
|
||||
// the row committed) left a persisted mood entry with no links while the
|
||||
// per-entry catch reported the entry as "skipped" — the client believes
|
||||
// nothing saved and a half-written row is on disk.
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const upserted = await tx.moodEntry.upsert({
|
||||
where: probeWhere,
|
||||
create: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the mood + tags + note triple. The
|
||||
// iOS client only re-posts an existing entry when it has
|
||||
// new data; the server trusts that decision. When the dedup
|
||||
// key is `externalId`, also refresh `date` / `moodLoggedAt`
|
||||
// so a re-zoned re-import lands the corrected wall-clock on
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
...(entry.externalId
|
||||
? { date, moodLoggedAt: entry.moodLoggedAt }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.12.0 — persist structured-tag links, mirroring the
|
||||
// single-entry `createTagLinks` path. Additive + idempotent:
|
||||
// `createTagLinks` resolves keys against the catalog (dropping
|
||||
// unknown keys) and `skipDuplicates` on the join insert keeps a
|
||||
// re-posted entry from minting duplicate links. Runs for both
|
||||
// fresh and re-posted (upserted) rows so a backfill that adds tag
|
||||
// keys on a second pass still lands them.
|
||||
// v1.12.0 — rated factors ride the same path; an out-of-scale
|
||||
// rating throws `RatedFactorOutOfRangeError`, which the per-entry
|
||||
// catch below turns into a `skipped` result (the rest of the batch
|
||||
// still lands). The mood row itself already upserted, so a skipped
|
||||
// factor leaves a valid entry with no rated links.
|
||||
if (
|
||||
(entry.tagKeys && entry.tagKeys.length > 0) ||
|
||||
(entry.ratedFactors && entry.ratedFactors.length > 0)
|
||||
) {
|
||||
await createTagLinks(
|
||||
result.id,
|
||||
user.id,
|
||||
entry.tagKeys ?? [],
|
||||
prisma,
|
||||
entry.ratedFactors ?? [],
|
||||
);
|
||||
}
|
||||
// v1.12.0 — persist structured-tag links, mirroring the
|
||||
// single-entry `createTagLinks` path. Additive + idempotent:
|
||||
// `createTagLinks` resolves keys against the catalog (dropping
|
||||
// unknown keys) and `skipDuplicates` on the join insert keeps a
|
||||
// re-posted entry from minting duplicate links. Runs for both
|
||||
// fresh and re-posted (upserted) rows so a backfill that adds tag
|
||||
// keys on a second pass still lands them.
|
||||
// v1.12.0 — rated factors ride the same path; an out-of-scale
|
||||
// rating throws `RatedFactorOutOfRangeError`. Inside the
|
||||
// transaction that throw now rolls the mood row back with it, so a
|
||||
// "skipped" result means nothing was written — which is what the
|
||||
// client was already being told.
|
||||
if (
|
||||
(entry.tagKeys && entry.tagKeys.length > 0) ||
|
||||
(entry.ratedFactors && entry.ratedFactors.length > 0)
|
||||
) {
|
||||
await createTagLinks(
|
||||
upserted.id,
|
||||
user.id,
|
||||
entry.tagKeys ?? [],
|
||||
tx,
|
||||
entry.ratedFactors ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return upserted;
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
duplicates += 1;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* user's IANA timezone (the `stats:` day-key contract — the server
|
||||
* trusts the string after regex + calendar sanity, no re-derivation).
|
||||
*
|
||||
* Upsert key = the composite PK (userId, day, nutrient). Re-post
|
||||
* Upsert key = the composite PK (userId, day, nutrient, source), and this
|
||||
* route always owns the APPLE_HEALTH row. Re-post
|
||||
* replaces amount / unit / externalSourceVersion — last-writer-wins,
|
||||
* the day-total contract; per-entry status is `updated` then, never
|
||||
* `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range`
|
||||
@@ -42,6 +43,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
|
||||
import {
|
||||
MAX_NUTRIENT_ENTRIES_PER_BATCH,
|
||||
nutrientBatchSchema,
|
||||
@@ -64,16 +66,6 @@ function isRealCalendarDay(day: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
|
||||
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
|
||||
* key beyond that cannot be a legitimate local day and is corruption.
|
||||
*/
|
||||
function maxAcceptableDay(now: Date): string {
|
||||
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
return limit.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type EntryStatus = "inserted" | "updated" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
@@ -133,7 +125,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const dayCeiling = maxAcceptableDay(new Date());
|
||||
const dayCeiling = maxAcceptableNutrientDay(new Date());
|
||||
|
||||
const results: EntryResult[] = new Array(entries.length);
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
nutrientIntakeDay: {
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -56,6 +57,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -216,3 +218,69 @@ describe("POST /api/nutrients/water", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/water — day bound and increment cap", () => {
|
||||
// The batch route bounded `day` with UTC+14 slack; this route validated
|
||||
// calendar realism only. Every read filters `day: { gte: since }` with no
|
||||
// upper bound, so a far-future row became the permanent `latestDay` on the
|
||||
// settings card and the permanent `lastSeenAt` on the dashboard water tile.
|
||||
it("rejects a far-future day with 422", async () => {
|
||||
const res = await POST(
|
||||
postReq({ amountMl: 500, mode: "set", day: "2999-01-01" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still accepts a day two days ahead — the UTC+14 slack the batch route allows", async () => {
|
||||
const soon = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const res = await POST(postReq({ amountMl: 500, mode: "set", day: soon }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts a past day", async () => {
|
||||
const past = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const res = await POST(postReq({ amountMl: 500, mode: "set", day: past }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// `mode: "add"` was unbounded across requests: the schema caps a SINGLE
|
||||
// write, so repeated quick-adds drove the stored day total arbitrarily high
|
||||
// while the batch route enforced the same cap strictly on the Apple row.
|
||||
it("clamps an incremented total back to the catalog's plausible daily max", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
|
||||
day: "2026-07-16",
|
||||
nutrient: "water",
|
||||
source: "MANUAL",
|
||||
amount: 99_000,
|
||||
unit: "ml",
|
||||
} as never);
|
||||
vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({
|
||||
day: "2026-07-16",
|
||||
nutrient: "water",
|
||||
source: "MANUAL",
|
||||
amount: NUTRIENT_CATALOG.water.plausibleDailyMax,
|
||||
unit: "ml",
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ amountMl: 500, mode: "add" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { amount: NUTRIENT_CATALOG.water.plausibleDailyMax },
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as WaterResponse;
|
||||
expect(body.data.amount).toBe(NUTRIENT_CATALOG.water.plausibleDailyMax);
|
||||
});
|
||||
|
||||
it("leaves a total under the cap untouched", async () => {
|
||||
const res = await POST(postReq({ amountMl: 500, mode: "add" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
|
||||
import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -79,7 +80,12 @@ async function postWater(request: NextRequest): Promise<Response> {
|
||||
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
const day = parsed.data.day ?? userDayKey(new Date(), userTz);
|
||||
if (!isRealCalendarDay(day)) {
|
||||
// Calendar realism AND the upper bound the batch route already applied. A
|
||||
// client-supplied far-future day used to be accepted here, and since every
|
||||
// read filters `day: { gte: since }` with no upper bound it became the
|
||||
// permanent `latestDay` on the settings card and the permanent `lastSeenAt`
|
||||
// on the dashboard water tile.
|
||||
if (!isRealCalendarDay(day) || day > maxAcceptableNutrientDay(new Date())) {
|
||||
return apiError("Invalid day", 422, {
|
||||
errorCode: "nutrient.water.invalid_day",
|
||||
});
|
||||
@@ -95,7 +101,7 @@ async function postWater(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
};
|
||||
|
||||
const row = await prisma.nutrientIntakeDay.upsert({
|
||||
let row = await prisma.nutrientIntakeDay.upsert({
|
||||
where: key,
|
||||
create: {
|
||||
userId: user.id,
|
||||
@@ -111,6 +117,19 @@ async function postWater(request: NextRequest): Promise<Response> {
|
||||
: { amount: amountMl },
|
||||
});
|
||||
|
||||
// The request schema bounds a SINGLE write to the catalog's plausible daily
|
||||
// max, but `mode: "add"` is unbounded across requests — repeated quick-adds
|
||||
// drove the stored day total arbitrarily high while the batch route enforced
|
||||
// the same cap strictly on the Apple-sourced row. Clamp after the fact rather
|
||||
// than reading first: the increment stays atomic, and the clamp converges on
|
||||
// the cap no matter how the increments interleave.
|
||||
if (row.amount > definition.plausibleDailyMax) {
|
||||
row = await prisma.nutrientIntakeDay.update({
|
||||
where: key,
|
||||
data: { amount: definition.plausibleDailyMax },
|
||||
});
|
||||
}
|
||||
|
||||
// Interactive single-entry write — hard-evict (not mark-stale) so the
|
||||
// dashboard water tile reflects the new total on the very next read,
|
||||
// matching the mood / medication / measurement posture.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* `DELETE /api/withings/credentials` — disconnect honesty.
|
||||
*
|
||||
* The handler deletes the connection row (holding the encrypted OAuth access +
|
||||
* refresh tokens) before nulling the credential columns. That delete used to be
|
||||
* `.catch(() => {})`: on a real failure the handler carried on, nulled the
|
||||
* client id/secret, and returned `{ deleted: true }` while the token-bearing
|
||||
* row survived — the user is told they are disconnected and they are not.
|
||||
*
|
||||
* It now narrows to P2025 ("already gone", a genuine no-op) and rethrows
|
||||
* everything else. Withings had no test file at all, which is why this one
|
||||
* exists; the WHOOP and Fitbit twins carry the same shape.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: { delete: vi.fn() },
|
||||
user: { update: 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("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } 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: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
/** Shape of a Prisma "record to delete does not exist" rejection. */
|
||||
function p2025() {
|
||||
return Object.assign(new Error("Record to delete does not exist."), {
|
||||
code: "P2025",
|
||||
name: "PrismaClientKnownRequestError",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/withings/credentials", () => {
|
||||
it("disconnects and nulls the credential columns on the happy path", async () => {
|
||||
vi.mocked(prisma.withingsConnection.delete).mockResolvedValue({} as never);
|
||||
const res = await DELETE();
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an already-missing connection row (P2025) as a benign no-op", async () => {
|
||||
vi.mocked(prisma.withingsConnection.delete).mockRejectedValue(p2025());
|
||||
const res = await DELETE();
|
||||
expect(res.status).toBe(200);
|
||||
// Idempotent disconnect still clears the credentials.
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT report success when the token-bearing row fails to delete", async () => {
|
||||
vi.mocked(prisma.withingsConnection.delete).mockRejectedValue(
|
||||
new Error("connection terminated unexpectedly"),
|
||||
);
|
||||
const res = await DELETE();
|
||||
expect(res.status).toBe(500);
|
||||
// Critically: the credentials must NOT be nulled, because doing so while
|
||||
// the encrypted OAuth tokens survive is the worst of both states.
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user