- Fix race conditions: Set workers: 1 since all tests share test user state - Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data from database instead of returning stale PocketBase auth store cache - Fix timing: Replace waitForTimeout with retry-based Playwright assertions - Fix mobile test: Use exact heading match to avoid strict mode violation - Add test user setup: Include notificationTime and update rule for users All 1014 unit tests and 190 E2E tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
// ABOUTME: Unit tests for user profile API route.
|
|
// 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";
|
|
|
|
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({});
|
|
|
|
// Track PocketBase getOne calls - returns the current mock user data
|
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
|
if (!currentMockUser) {
|
|
throw new Error("User not found");
|
|
}
|
|
return Promise.resolve({
|
|
id: currentMockUser.id,
|
|
email: currentMockUser.email,
|
|
garminConnected: currentMockUser.garminConnected,
|
|
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
|
cycleLength: currentMockUser.cycleLength,
|
|
notificationTime: currentMockUser.notificationTime,
|
|
timezone: currentMockUser.timezone,
|
|
activeOverrides: currentMockUser.activeOverrides,
|
|
});
|
|
});
|
|
|
|
// Create mock PocketBase client
|
|
const mockPb = {
|
|
collection: vi.fn(() => ({
|
|
update: mockPbUpdate,
|
|
getOne: mockPbGetOne,
|
|
})),
|
|
};
|
|
|
|
// Mock the auth-middleware module
|
|
vi.mock("@/lib/auth-middleware", () => ({
|
|
withAuth: vi.fn((handler) => {
|
|
return async (request: NextRequest) => {
|
|
if (!currentMockUser) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
return handler(request, currentMockUser, mockPb);
|
|
};
|
|
}),
|
|
}));
|
|
|
|
import { GET, PATCH } from "./route";
|
|
|
|
describe("GET /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();
|
|
mockPbGetOne.mockClear();
|
|
});
|
|
|
|
it("returns user profile when authenticated", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
// Should include these fields
|
|
expect(body.id).toBe("user123");
|
|
expect(body.email).toBe("test@example.com");
|
|
expect(body.garminConnected).toBe(true);
|
|
expect(body.cycleLength).toBe(28);
|
|
expect(body.lastPeriodDate).toBe("2025-01-15");
|
|
expect(body.notificationTime).toBe("07:30");
|
|
expect(body.timezone).toBe("America/New_York");
|
|
});
|
|
|
|
it("does not expose sensitive token fields", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
const body = await response.json();
|
|
|
|
// Should NOT include encrypted tokens
|
|
expect(body.garminOauth1Token).toBeUndefined();
|
|
expect(body.garminOauth2Token).toBeUndefined();
|
|
expect(body.calendarToken).toBeUndefined();
|
|
});
|
|
|
|
it("includes activeOverrides array", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
const body = await response.json();
|
|
|
|
expect(body.activeOverrides).toEqual(["flare"]);
|
|
});
|
|
|
|
it("returns 401 when not authenticated", async () => {
|
|
currentMockUser = null;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
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();
|
|
mockPbGetOne.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();
|
|
});
|
|
});
|