mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(profile): report the time zone the server uses on /api/user/profile
`/api/auth/me` has reported the resolved zone, the stored one or the instance default when the stored value is unusable, while `/api/user/profile` returned the raw column. A client reading the profile could cut days differently from the server, and sending the value back in a PATCH was refused. Both reads, and the profile PATCH answer, now go through one `resolveStoredTimezone`. The field stays a string and always holds a valid IANA name. The web account editor already reads the resolved zone from `/api/auth/me` and saves through the validating timezone route, so it shows the zone in use and can only write a valid one.
This commit is contained in:
1 parent
0cc73d3c88
commit
be112df972
6 files changed
+124
-11
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user