Implement POST /api/cycle/period endpoint (P1.2)
Add period logging endpoint that allows users to record their period start date. This is a critical path item that unblocks GET /api/cycle/current and GET /api/today. Features: - Protected with withAuth middleware - Validates startDate is present, valid format (YYYY-MM-DD), and not in future - Updates user.lastPeriodDate in PocketBase - Creates PeriodLog record for historical tracking - Returns updated cycle information (cycleDay, phase) Tests: 8 tests covering authentication, validation, database operations, and error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
208
src/app/api/cycle/period/route.test.ts
Normal file
208
src/app/api/cycle/period/route.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// ABOUTME: Unit tests for period logging API route.
|
||||
// ABOUTME: Tests POST /api/cycle/period for period start date logging.
|
||||
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;
|
||||
|
||||
// Mock PocketBase client for database operations
|
||||
const mockPbUpdate = vi.fn();
|
||||
const mockPbCreate = vi.fn();
|
||||
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
collection: vi.fn((_name: string) => ({
|
||||
update: mockPbUpdate,
|
||||
create: mockPbCreate,
|
||||
})),
|
||||
})),
|
||||
loadAuthFromCookies: vi.fn(),
|
||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
||||
getCurrentUser: vi.fn(() => currentMockUser),
|
||||
}));
|
||||
|
||||
// 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);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
describe("POST /api/cycle/period", () => {
|
||||
const mockUser: User = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
garminConnected: false,
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2024-12-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
mockPbUpdate.mockResolvedValue({});
|
||||
mockPbCreate.mockResolvedValue({ id: "periodlog123" });
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
currentMockUser = null;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 400 when startDate is missing", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("startDate");
|
||||
});
|
||||
|
||||
it("returns 400 when startDate is invalid format", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "invalid-date" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("Invalid");
|
||||
});
|
||||
|
||||
it("returns 400 when startDate is in the future", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
// Set a date far in the future
|
||||
const futureDate = new Date();
|
||||
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
||||
const futureDateStr = futureDate.toISOString().split("T")[0];
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: futureDateStr }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("future");
|
||||
});
|
||||
|
||||
it("updates user lastPeriodDate and creates PeriodLog", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify user record was updated
|
||||
expect(mockPbUpdate).toHaveBeenCalledWith(
|
||||
"user123",
|
||||
expect.objectContaining({
|
||||
lastPeriodDate: "2025-01-10",
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify PeriodLog was created
|
||||
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: "user123",
|
||||
startDate: "2025-01-10",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns updated cycle information", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
// Should return cycle info
|
||||
expect(body.lastPeriodDate).toBe("2025-01-10");
|
||||
expect(body.cycleDay).toBeTypeOf("number");
|
||||
expect(body.phase).toBeTypeOf("string");
|
||||
expect(body.message).toBe("Period start date logged successfully");
|
||||
});
|
||||
|
||||
it("calculates correct cycle day for new period", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
// If period started today, cycle day should be 1
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: today }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.cycleDay).toBe(1);
|
||||
expect(body.phase).toBe("MENSTRUAL");
|
||||
});
|
||||
|
||||
it("handles database update errors gracefully", async () => {
|
||||
currentMockUser = mockUser;
|
||||
mockPbUpdate.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Failed to update period date");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,96 @@
|
||||
// ABOUTME: API route for logging period start dates.
|
||||
// ABOUTME: Recalculates all phase dates when period is logged.
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement period logging
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
import { withAuth } from "@/lib/auth-middleware";
|
||||
import { getCycleDay, getPhase } from "@/lib/cycle";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
|
||||
interface PeriodLogRequest {
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a date string is in YYYY-MM-DD format and represents a valid date.
|
||||
*/
|
||||
function isValidDateFormat(dateStr: string): boolean {
|
||||
const regex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!regex.test(dateStr)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(dateStr);
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a date is in the future (after today).
|
||||
*/
|
||||
function isFutureDate(dateStr: string): boolean {
|
||||
const inputDate = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
inputDate.setHours(0, 0, 0, 0);
|
||||
return inputDate > today;
|
||||
}
|
||||
|
||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
try {
|
||||
const body = (await request.json()) as PeriodLogRequest;
|
||||
|
||||
// Validate startDate is present
|
||||
if (!body.startDate) {
|
||||
return NextResponse.json(
|
||||
{ error: "startDate is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (!isValidDateFormat(body.startDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid date format. Use YYYY-MM-DD" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate date is not in the future
|
||||
if (isFutureDate(body.startDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: "startDate cannot be in the future" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Update user's lastPeriodDate
|
||||
await pb.collection("users").update(user.id, {
|
||||
lastPeriodDate: body.startDate,
|
||||
});
|
||||
|
||||
// Create PeriodLog record
|
||||
await pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: body.startDate,
|
||||
});
|
||||
|
||||
// Calculate updated cycle information
|
||||
const lastPeriodDate = new Date(body.startDate);
|
||||
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Period start date logged successfully",
|
||||
lastPeriodDate: body.startDate,
|
||||
cycleDay,
|
||||
phase,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Period logging error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update period date" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user