Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/measurements.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 08:04:30 +02:00
commit d0e321c54a
28 files changed
+2399 -87

No files matched your search

+2 -7
View File
@@ -49,10 +49,7 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -279,9 +276,7 @@ export const GET = apiHandler(async () => {
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
timezone: await resolveStoredTimezone(user.timezone),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
@@ -203,3 +203,82 @@ describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () =>
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/cycle/day-logs/bulk — refusal codes", () => {
/**
* The native client files rows under a permanent-refusal register as soon
* as it can tell the refusal is permanent. It can only tell from the code,
* so both whole-batch 422s are pinned here, with the envelope they ride.
*/
async function refusal(body: unknown) {
const res = await POST(postReq(body));
return {
status: res.status,
body: (await res.json()) as {
data: null;
error: string;
details?: { issues: unknown[] };
meta?: { errorCode?: string };
},
};
}
it("names a malformed batch cycle.bulk.invalid, with every issue listed", async () => {
const { status, body } = await refusal({
entries: [{ date: "not-a-day" }, { date: "2026-13-40" }],
});
expect(status).toBe(422);
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
expect(body.details?.issues.length).toBeGreaterThanOrEqual(2);
expect(upsertCycleDayLog).not.toHaveBeenCalled();
});
it("names a batch with no entries array cycle.bulk.invalid", async () => {
const { status, body } = await refusal({});
expect(status).toBe(422);
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
});
it("names an over-cap batch cycle.bulk.too_large", async () => {
const entries = Array.from({ length: 501 }, (_, i) => ({
date: "2026-01-01",
externalId: `e${i}`,
}));
const { status, body } = await refusal({ entries });
expect(status).toBe(422);
expect(body.meta?.errorCode).toBe("cycle.bulk.too_large");
});
});
describe("POST /api/cycle/day-logs/bulk — a failed write is not cached", () => {
it("marks a response carrying upsert_failed no-store and leaves a clean one alone", async () => {
vi.mocked(upsertCycleDayLog).mockRejectedValueOnce(new Error("reset"));
const failed = await POST(
postReq({
entries: [{ date: "2026-01-01", loggedAt: "2026-01-01T08:00:00.000Z" }],
}),
);
expect(failed.status).toBe(200);
const body = (await failed.json()) as {
data: { entries: Array<{ status: string; reason?: string }> };
};
expect(body.data.entries[0]).toMatchObject({
status: "skipped",
reason: "upsert_failed",
});
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
vi.mocked(upsertCycleDayLog).mockResolvedValueOnce({
id: "d1",
existed: false,
changed: true,
} as never);
const clean = await POST(
postReq({
entries: [{ date: "2026-01-02", loggedAt: "2026-01-02T08:00:00.000Z" }],
}),
);
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
});
});
+9 -1
View File
@@ -249,7 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted,
updated,
@@ -257,4 +257,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
skipped,
entries: results,
});
// `upsert_failed` is a write that did not happen, not a verdict on the
// entry, and the client sends it again. `no-store` keeps the idempotency
// layer from caching this answer, so a retry under the same key writes
// instead of replaying the failure.
if (results.some((r) => r.reason === "upsert_failed")) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
@@ -0,0 +1,231 @@
/**
* `POST /api/measurements/batch` — what a refusal tells the client.
*
* Two gaps the native client reported against v1.39.0:
*
* - The whole-batch validation 422 carried no `meta.errorCode`, so the
* client could not tell a permanent refusal from a transient one and had
* to hold its anchor for several syncs before giving up on the rows.
* The too-large and source refusals already had codes; this one now has
* `measurement.batch.invalid`.
* - A `value_out_of_range` skip said neither what the value became after
* conversion nor which band it broke, so a unit-mapping slip (a fraction
* sent where a percent was expected) looked the same as a sensor glitch.
* The entry now carries `convertedValue` and `range`.
*
* Both are additive: status codes, the envelope and every existing field
* are unchanged.
*/
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() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
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/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
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";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function post(body: unknown) {
return POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
const AT = new Date(Date.now() - 3_600_000).toISOString();
function entry(overrides: Record<string, unknown> = {}) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: AT,
endDate: AT,
externalId: "uuid-1",
...overrides,
};
}
type EntryResult = {
index: number;
status: string;
reason?: string;
convertedValue?: number;
range?: { min: number; max: number; unit: string };
};
async function entriesFor(entries: unknown[]): Promise<EntryResult[]> {
const res = await post({ entries });
expect(res.status).toBe(200);
return ((await res.json()) as { data: { entries: EntryResult[] } }).data
.entries;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
});
describe("POST /api/measurements/batch — validation refusal code", () => {
it("names a malformed batch measurement.batch.invalid and keeps the 422 envelope", async () => {
const res = await post({ entries: [entry({ startDate: "yesterday" })] });
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: { issues: unknown[] };
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(typeof body.error).toBe("string");
expect(body.details.issues.length).toBeGreaterThan(0);
expect(body.meta?.errorCode).toBe("measurement.batch.invalid");
});
it("names an empty entries array the same way", async () => {
const res = await post({ entries: [] });
expect(res.status).toBe(422);
expect(
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("measurement.batch.invalid");
});
it("keeps measurement.batch.too_large for an over-cap batch", async () => {
const entries = Array.from({ length: 501 }, (_, i) =>
entry({ externalId: `uuid-${i}` }),
);
const res = await post({ entries });
expect(res.status).toBe(422);
expect(
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("measurement.batch.too_large");
});
});
describe("POST /api/measurements/batch — value_out_of_range detail", () => {
it("reports the converted value and the band it broke, in the stored unit", async () => {
// HealthKit reports body fat as a fraction; 0.9 becomes 90 %, above 80.
const [result] = await entriesFor([
entry({
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
value: 0.9,
unit: "%",
}),
]);
expect(result).toEqual({
index: 0,
status: "skipped",
reason: "value_out_of_range",
convertedValue: 90,
range: { min: 1, max: 80, unit: "%" },
});
});
it("reports the lower edge too", async () => {
const [result] = await entriesFor([entry({ value: 12 })]);
expect(result).toMatchObject({
reason: "value_out_of_range",
convertedValue: 12,
range: { min: 20, max: 300, unit: "bpm" },
});
});
it("carries the same detail when the write-side gate is the one that refuses", async () => {
vi.mocked(reconcileExternalMeasurement).mockResolvedValueOnce({
status: "rejected_range",
rejection: { type: "PULSE", direction: "above_max" },
});
const [result] = await entriesFor([entry({ value: 64 })]);
expect(result).toMatchObject({
status: "skipped",
reason: "value_out_of_range",
convertedValue: 64,
range: { min: 20, max: 300, unit: "bpm" },
});
});
it("adds nothing to an entry that landed or was skipped for another reason", async () => {
const results = await entriesFor([
entry({ externalId: "uuid-ok" }),
entry({ hkIdentifier: "HKQuantityTypeIdentifierNotAThing" }),
]);
expect(results[0]).toEqual({ index: 0, status: "inserted" });
expect(results[1]).toEqual({
index: 1,
status: "skipped",
reason: "unmappable_identifier",
});
});
});
+44 -11
View File
@@ -72,7 +72,11 @@ import {
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import {
VALUE_RANGES,
getUnitForType,
validateMeasurementRange,
} from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
@@ -217,6 +221,36 @@ interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
/**
* Only on a `value_out_of_range` skip: the value after conversion into the
* stored unit, which is what the band was checked against.
*/
convertedValue?: number;
/** Only on a `value_out_of_range` skip: the band applied, in the stored unit. */
range?: { min: number; max: number; unit: string };
}
/**
* The `value_out_of_range` skip, with what a client needs to say which limit
* applied. The value is the entry's own reading after conversion, handed back
* to the account that sent it; a fraction sent where a percent was expected
* shows up here as a converted value a hundred times too small or too large.
*/
function outOfRangeResult(
index: number,
type: string,
convertedValue: number,
): EntryResult {
const band = VALUE_RANGES[type];
return {
index,
status: "skipped",
reason: "value_out_of_range",
...(Number.isFinite(convertedValue) ? { convertedValue } : {}),
...(band
? { range: { min: band.min, max: band.max, unit: getUnitForType(type) } }
: {}),
};
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
@@ -277,10 +311,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
const parsed = batchPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
// The code is what lets a client tell this refusal from a transient one:
// the batch as sent will never be accepted, so resending it is pointless.
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "measurement.batch.invalid" },
);
}
@@ -373,11 +410,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
// sensor reading shouldn't poison an otherwise clean ingest.
const rangeError = validateMeasurementRange(mapped.type, mapped.value);
if (rangeError !== null) {
results[index] = {
index,
status: "skipped",
reason: "value_out_of_range",
};
results[index] = outOfRangeResult(index, mapped.type, mapped.value);
continue;
}
@@ -678,11 +711,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
// already applied above, so reaching here means the two disagreed —
// report it exactly as the earlier guard would have, and let the
// wide-event tally record which end refused.
results[p.index] = {
index: p.index,
status: "skipped",
reason: "value_out_of_range",
};
results[p.index] = outOfRangeResult(
p.index,
p.row.type as string,
p.row.value as number,
);
continue;
}
+196
View File
@@ -55,6 +55,21 @@ import {
pickMainNightAndNaps,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
isNamedSleepStageBody,
sleepStageEntrySchema,
toSleepStageSegment,
} from "@/lib/validations/sleep-stage-entry";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import {
MEASURED_AT_TOLERANCE_MS,
isMergeableSource,
isSameReadingAcrossSource,
oppositeMergeSource,
} from "@/lib/measurements/cross-source-merge";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -765,6 +780,13 @@ async function postMeasurement(request: NextRequest) {
);
}
// One sleep-stage segment, the stage named rather than coded. A bridge
// (Tasker, Home Assistant) posts here with its narrow token; the row and
// its dedupe are the batch route's. See `sleep-stage-entry.ts`.
if (isNamedSleepStageBody(body)) {
return postNamedSleepStage(request, user.id, body, scoped);
}
// Batch mode (array of measurements, e.g. combined BP + Pulse)
if (Array.isArray(body)) {
const parsed = createBatchMeasurementSchema.safeParse({
@@ -1084,3 +1106,177 @@ async function postMeasurement(request: NextRequest) {
return apiSuccess(shapeMeasurementNotes(measurement), 201);
}
/**
* `POST /api/measurements` with a named `sleepStage`: one segment, stored as
* the batch route stores a HealthKit sleep sample.
*
* The same three steps as the batch route, in the same order: a same-reading
* row of the opposite source (MANUAL against APPLE_HEALTH) makes this one a
* duplicate; otherwise `reconcileExternalMeasurement` settles both unique
* identities at once, reporting an exact external-id match as a duplicate
* and adopting a row that only shares the natural identity. So a bridge that
* re-posts the same segment gets one row, whichever path it used first.
*
* `201` with the stored row when it was inserted, `200` when it was already
* there (`duplicate`) or was corrected in place (`updated`). The row carries
* a `status` saying which.
*/
async function postNamedSleepStage(
request: NextRequest,
userId: string,
body: unknown,
scoped: boolean,
): Promise<Response> {
const parsed = sleepStageEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "measurements.create.sleep-stage.validation-failed" },
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "measurement.create.invalid",
});
}
const segment = toSleepStageSegment(parsed.data);
// Under a scoped credential the source is resolved from the credential,
// as on the other arms of this route: the refusal above already returned
// on any body that named one.
const source = (
scoped ? EXTERNAL_SOURCE : parsed.data.source
) as MeasurementSource;
const type: MeasurementType = "SLEEP_DURATION";
// Cross-source same reading, exactly as the batch route decides it.
if (isMergeableSource(source)) {
const candidates = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type,
source: oppositeMergeSource(source),
measuredAt: {
gte: new Date(segment.end.getTime() - MEASURED_AT_TOLERANCE_MS),
lte: new Date(segment.end.getTime() + MEASURED_AT_TOLERANCE_MS),
},
},
select: {
id: true,
type: true,
source: true,
value: true,
measuredAt: true,
},
});
const twin = candidates.find((candidate) =>
isSameReadingAcrossSource(
{ type, source, value: segment.minutes, measuredAt: segment.end },
candidate,
),
);
if (twin) {
return namedSleepStageResponse(twin.id, "duplicate", {
stage: segment.stage,
source,
crossSource: true,
});
}
}
const verdict = await prisma.$transaction((tx) =>
reconcileExternalMeasurement(
tx,
{
userId,
type,
value: segment.minutes,
unit: getUnitForType(type),
source,
measuredAt: segment.end,
externalId: segment.externalId,
externalSourceVersion: null,
sleepStage: segment.stage,
deviceType: parsed.data.deviceType ?? null,
},
{ exactExternalMatch: "duplicate" },
),
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
if (verdict.status === "rejected_range") {
// The schema applies the same band, so this is the two gates disagreeing.
return apiError("Value out of plausible range", 422, {
errorCode: "measurement.create.invalid",
});
}
const status =
verdict.status === "inserted"
? "inserted"
: verdict.status === "duplicate"
? "duplicate"
: "updated";
if (status !== "duplicate") {
await auditLog("measurement.create", {
userId,
ipAddress: getClientIp(request),
details: {
type,
measurementId: verdict.row.id,
sleepStage: segment.stage,
status,
},
});
invalidateUserMeasurements(userId, { evict: true });
fireAndForget(enqueueReminderSatisfy(userId), {
action: "reminder.satisfy.enqueue",
});
if (status === "inserted") {
void emitInsertedMeasurementArrivals(
userId,
[verdict.row],
"manual",
).catch(() => {});
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
}
await afterMeasurementMutation(userId, [
{ type, measuredAt: segment.end },
...(verdict.dirtyIdentities ?? []),
]);
}
return namedSleepStageResponse(verdict.row.id, status, {
stage: segment.stage,
source,
crossSource: false,
});
}
async function namedSleepStageResponse(
id: string,
status: "inserted" | "updated" | "duplicate",
meta: { stage: string; source: string; crossSource: boolean },
): Promise<Response> {
annotate({
action: {
name: "measurement.create.sleep-stage",
entity_type: "measurement",
entity_id: id,
},
meta: {
status,
stage: meta.stage,
source: meta.source,
cross_source_merge: meta.crossSource,
},
});
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
return apiSuccess(
{ ...shapeMeasurementNotes(row), status },
status === "inserted" ? 201 : 200,
);
}
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -449,6 +450,46 @@ describe("POST /api/nutrients/batch — write semantics", () => {
spy.mockRestore();
});
it("marks a response carrying upsert_failed no-store, so a retry with the same Idempotency-Key writes again", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
new Error("connection reset"),
);
const failed = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
],
}),
);
expect(failed.status).toBe(200);
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
spy.mockRestore();
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
count: 1,
} as never);
const clean = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
],
}),
);
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
});
it("documents upsert_failed as a transient write failure to retry, and the validation skips as terminal", () => {
const description = String(
(nutrientEntryResultSchema.meta() as { description?: string })
.description,
);
expect(description).toMatch(/`upsert_failed` is a write failure/);
expect(description).toMatch(/sent again on a later sync/);
expect(description).not.toMatch(/log and drop a skipped entry/i);
expect(description).toMatch(/unit_mismatch/);
});
it("a race that drops one createMany row still counts consistently and is observable", async () => {
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
count: 1,
+13 -2
View File
@@ -18,7 +18,10 @@
* per-row updates (existing pairs), the measurements-batch shape. A write
* failure marks its WHOLE group `skipped upsert_failed` rather than
* isolating the one entry that would have caused it (unavoidable once the
* write is batched) — the request itself never fails.
* write is batched) — the request itself never fails. That reason is
* transient, unlike the validation skips: the client sends those entries
* again, and the response is marked `no-store` so an idempotent retry is not
* answered from the cache.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
@@ -360,11 +363,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
return apiSuccess({
const response = apiSuccess({
processed: entries.length,
inserted,
updated,
skipped,
entries: results,
});
// `upsert_failed` is transient: nothing was stored for those entries and the
// client sends them again. `no-store` keeps the idempotency layer from
// caching this answer, so a retry under the same key writes instead of
// replaying the failure. The measurement batch does the same for `failed`.
if (skippedByReason.upsert_failed) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
@@ -60,6 +61,12 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
// The operator's default zone, which the server falls back to when a
// stored one is unusable. Distinct from the built-in default so a test can
// tell the resolver ran.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultUserTimezone: "America/Chicago",
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -438,3 +445,81 @@ describe("PATCH /api/user/profile", () => {
]);
});
});
/**
* `/api/auth/me` has returned the zone the server actually cuts days in —
* the stored one, or the instance default when the stored value is unusable
* — while this alias returned the raw column. A client reading this endpoint
* could bucket a day differently from the server. Both now go through the
* same resolution.
*/
describe("/api/user/profile — timezone is the resolved zone", () => {
function profileRow(timezone: string | null) {
return {
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone,
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
};
}
async function getZone(stored: string | null): Promise<string> {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
profileRow(stored) as never,
);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
return ((await res.json()) as { data: { timezone: string } }).data.timezone;
}
it("returns a usable stored zone as stored", async () => {
expect(await getZone("Asia/Tokyo")).toBe("Asia/Tokyo");
});
it("returns the instance default in place of a bare offset", async () => {
expect(await getZone("+05:30")).toBe("America/Chicago");
});
it("returns the instance default in place of a name no runtime knows", async () => {
expect(await getZone("Mars/Olympus_Mons")).toBe("America/Chicago");
});
it("returns the instance default when the row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await callGet(makeGetReq());
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
it("answers a PATCH that leaves an unusable zone untouched with the resolved one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
...profileRow("+05:30"),
id: "user-1",
role: "USER",
displayName: "Alex T.",
} as never);
const res = await PATCH(
new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName: "Alex T." }),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
});
+6 -2
View File
@@ -20,6 +20,7 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
@@ -77,7 +78,10 @@ export const GET = apiHandler(async () => {
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
// The zone the server cuts this account's days in, exactly as
// `/api/auth/me` reports it: the stored zone, or the instance default
// when the stored value is unusable. Never the raw column.
timezone: await resolveStoredTimezone(dbUser?.timezone),
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
@@ -118,7 +122,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
timezone: await resolveStoredTimezone(result.user.timezone),
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,