Implement PATCH /api/user endpoint (P1.1)

Add profile update functionality with validation for:
- cycleLength: number, range 21-45 days
- notificationTime: string, HH:MM format (24-hour)
- timezone: non-empty string

Security: Ignores attempts to update non-updatable fields (email, tokens).
Returns updated user profile excluding sensitive fields.

17 tests covering validation, persistence, and security scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:14:12 +00:00
parent e4d123704d
commit 18c34916ca
3 changed files with 422 additions and 11 deletions

View File

@@ -1,5 +1,5 @@
// ABOUTME: Unit tests for user profile API route.
// ABOUTME: Tests GET /api/user for profile retrieval with authentication.
// ABOUTME: Tests GET and PATCH /api/user for profile retrieval and updates.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,6 +9,18 @@ import type { User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
update: mockPbUpdate,
})),
})),
}));
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
@@ -21,7 +33,7 @@ vi.mock("@/lib/auth-middleware", () => ({
}),
}));
import { GET } from "./route";
import { GET, PATCH } from "./route";
describe("GET /api/user", () => {
const mockUser: User = {
@@ -44,6 +56,7 @@ describe("GET /api/user", () => {
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockPbUpdate.mockClear();
});
it("returns user profile when authenticated", async () => {
@@ -99,3 +112,269 @@ describe("GET /api/user", () => {
expect(body.error).toBe("Unauthorized");
});
});
describe("PATCH /api/user", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: ["flare"],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockPbUpdate.mockClear();
});
// Helper to create mock request with JSON body
function createMockRequest(body: Record<string, unknown>): NextRequest {
return {
json: vi.fn().mockResolvedValue(body),
} as unknown as NextRequest;
}
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest({ cycleLength: 30 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("updates cycleLength successfully", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ cycleLength: 30 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleLength).toBe(30);
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
});
it("updates notificationTime successfully", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ notificationTime: "08:00" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.notificationTime).toBe("08:00");
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
notificationTime: "08:00",
});
});
it("updates timezone successfully", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ timezone: "Europe/London" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.timezone).toBe("Europe/London");
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
timezone: "Europe/London",
});
});
it("updates multiple fields at once", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
cycleLength: 35,
notificationTime: "06:00",
timezone: "Asia/Tokyo",
});
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleLength).toBe(35);
expect(body.notificationTime).toBe("06:00");
expect(body.timezone).toBe("Asia/Tokyo");
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
cycleLength: 35,
notificationTime: "06:00",
timezone: "Asia/Tokyo",
});
});
it("returns 400 when cycleLength is below minimum (21)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ cycleLength: 20 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("cycleLength");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when cycleLength is above maximum (45)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ cycleLength: 46 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("cycleLength");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when cycleLength is not a number", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ cycleLength: "thirty" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("cycleLength");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when notificationTime has invalid format", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ notificationTime: "8am" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("notificationTime");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when notificationTime has invalid hours", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ notificationTime: "25:00" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("notificationTime");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when notificationTime has invalid minutes", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ notificationTime: "12:60" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("notificationTime");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when timezone is not a string", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ timezone: 123 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("timezone");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when timezone is empty string", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ timezone: "" });
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("timezone");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 with empty body", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({});
const response = await PATCH(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("No valid fields");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("ignores non-updatable fields like email", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
email: "hacker@evil.com",
cycleLength: 30,
});
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Email should NOT be changed
expect(body.email).toBe("test@example.com");
// Only cycleLength should be in the update
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
});
it("ignores attempts to update sensitive fields", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
garminOauth1Token: "stolen-token",
calendarToken: "new-calendar-token",
cycleLength: 30,
});
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
// Only cycleLength should be in the update
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
});
it("returns updated user profile after successful update", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ cycleLength: 32 });
const response = await PATCH(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Should return full profile with updated value
expect(body.id).toBe("user123");
expect(body.email).toBe("test@example.com");
expect(body.cycleLength).toBe(32);
expect(body.notificationTime).toBe("07:30");
expect(body.timezone).toBe("America/New_York");
// Should not expose sensitive fields
expect(body.garminOauth1Token).toBeUndefined();
expect(body.garminOauth2Token).toBeUndefined();
expect(body.calendarToken).toBeUndefined();
});
});