Implement POST/DELETE /api/overrides endpoints (P1.5)

Add override management API for the training decision system:
- POST /api/overrides adds an override (flare, stress, sleep, pms)
- DELETE /api/overrides removes an override
- Both endpoints use withAuth middleware
- Validation for override types, idempotent operations
- 14 tests covering auth, validation, and persistence

Also fix type error in today/route.ts where DailyLog body battery
fields could be null but biometrics object expected numbers.

🤖 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:09:08 +00:00
parent 949cb1671a
commit e4d123704d
4 changed files with 433 additions and 18 deletions

View File

@@ -0,0 +1,311 @@
// ABOUTME: Unit tests for overrides API route.
// ABOUTME: Tests POST and DELETE /api/overrides for managing user override toggles.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OverrideType, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track updates to the user
let lastUpdateCall: {
id: string;
data: { activeOverrides: OverrideType[] };
} | null = null;
// 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);
};
}),
}));
// Mock the pocketbase module
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
update: vi.fn(
async (id: string, data: { activeOverrides: OverrideType[] }) => {
lastUpdateCall = { id, data };
// Update the mock user to simulate DB update
if (currentMockUser) {
currentMockUser = {
...currentMockUser,
activeOverrides: data.activeOverrides,
};
}
return { ...currentMockUser, ...data };
},
),
})),
})),
}));
import { DELETE, POST } from "./route";
describe("POST /api/overrides", () => {
const createMockUser = (overrides: OverrideType[] = []): 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: overrides,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
});
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
lastUpdateCall = null;
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = {
json: async () => ({ override: "flare" }),
} 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 for invalid override type", async () => {
currentMockUser = createMockUser();
const mockRequest = {
json: async () => ({ override: "invalid" }),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("Invalid override type");
});
it("returns 400 when override field is missing", async () => {
currentMockUser = createMockUser();
const mockRequest = {
json: async () => ({}),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("override");
});
it("adds flare override to user", async () => {
currentMockUser = createMockUser([]);
const mockRequest = {
json: async () => ({ override: "flare" }),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).toContain("flare");
expect(lastUpdateCall?.data.activeOverrides).toContain("flare");
});
it("adds stress override to existing overrides", async () => {
currentMockUser = createMockUser(["flare"]);
const mockRequest = {
json: async () => ({ override: "stress" }),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).toContain("flare");
expect(body.activeOverrides).toContain("stress");
});
it("is idempotent - adding existing override does not duplicate", async () => {
currentMockUser = createMockUser(["flare"]);
const mockRequest = {
json: async () => ({ override: "flare" }),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Should only have one "flare", not two
expect(
body.activeOverrides.filter((o: string) => o === "flare").length,
).toBe(1);
});
it("accepts all valid override types", async () => {
const validTypes: OverrideType[] = ["flare", "stress", "sleep", "pms"];
for (const overrideType of validTypes) {
currentMockUser = createMockUser([]);
const mockRequest = {
json: async () => ({ override: overrideType }),
} as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).toContain(overrideType);
}
});
});
describe("DELETE /api/overrides", () => {
const createMockUser = (overrides: OverrideType[] = []): 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: overrides,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
});
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
lastUpdateCall = null;
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = {
json: async () => ({ override: "flare" }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 400 for invalid override type", async () => {
currentMockUser = createMockUser(["flare"]);
const mockRequest = {
json: async () => ({ override: "invalid" }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("Invalid override type");
});
it("returns 400 when override field is missing", async () => {
currentMockUser = createMockUser(["flare"]);
const mockRequest = {
json: async () => ({}),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("override");
});
it("removes flare override from user", async () => {
currentMockUser = createMockUser(["flare", "stress"]);
const mockRequest = {
json: async () => ({ override: "flare" }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).not.toContain("flare");
expect(body.activeOverrides).toContain("stress");
});
it("is idempotent - removing non-existent override succeeds", async () => {
currentMockUser = createMockUser(["stress"]);
const mockRequest = {
json: async () => ({ override: "flare" }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).not.toContain("flare");
expect(body.activeOverrides).toContain("stress");
});
it("can remove last override leaving empty array", async () => {
currentMockUser = createMockUser(["pms"]);
const mockRequest = {
json: async () => ({ override: "pms" }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).toEqual([]);
});
it("accepts all valid override types for removal", async () => {
const validTypes: OverrideType[] = ["flare", "stress", "sleep", "pms"];
for (const overrideType of validTypes) {
currentMockUser = createMockUser([overrideType]);
const mockRequest = {
json: async () => ({ override: overrideType }),
} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.activeOverrides).not.toContain(overrideType);
}
});
});

View File

@@ -1,13 +1,102 @@
// ABOUTME: API route for managing training overrides.
// ABOUTME: Handles flare, stress, sleep, and PMS override toggles.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export async function POST() {
// TODO: Implement override setting
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { OverrideType } from "@/types";
const VALID_OVERRIDE_TYPES: OverrideType[] = [
"flare",
"stress",
"sleep",
"pms",
];
function isValidOverrideType(value: unknown): value is OverrideType {
return (
typeof value === "string" &&
VALID_OVERRIDE_TYPES.includes(value as OverrideType)
);
}
export async function DELETE() {
// TODO: Implement override removal
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}
/**
* POST /api/overrides - Add an override to the user's active overrides.
* Request body: { override: OverrideType }
* Response: { activeOverrides: OverrideType[] }
*/
export const POST = withAuth(async (request: NextRequest, user) => {
const body = await request.json();
if (!body.override) {
return NextResponse.json(
{ error: "Missing required field: override" },
{ status: 400 },
);
}
if (!isValidOverrideType(body.override)) {
return NextResponse.json(
{
error: `Invalid override type: ${body.override}. Valid types: ${VALID_OVERRIDE_TYPES.join(", ")}`,
},
{ status: 400 },
);
}
const overrideToAdd: OverrideType = body.override;
// Build the new array, avoiding duplicates
const currentOverrides = user.activeOverrides || [];
const newOverrides = currentOverrides.includes(overrideToAdd)
? currentOverrides
: [...currentOverrides, overrideToAdd];
// Update the user record in PocketBase
const pb = createPocketBaseClient();
await pb
.collection("users")
.update(user.id, { activeOverrides: newOverrides });
return NextResponse.json({ activeOverrides: newOverrides });
});
/**
* DELETE /api/overrides - Remove an override from the user's active overrides.
* Request body: { override: OverrideType }
* Response: { activeOverrides: OverrideType[] }
*/
export const DELETE = withAuth(async (request: NextRequest, user) => {
const body = await request.json();
if (!body.override) {
return NextResponse.json(
{ error: "Missing required field: override" },
{ status: 400 },
);
}
if (!isValidOverrideType(body.override)) {
return NextResponse.json(
{
error: `Invalid override type: ${body.override}. Valid types: ${VALID_OVERRIDE_TYPES.join(", ")}`,
},
{ status: 400 },
);
}
const overrideToRemove: OverrideType = body.override;
// Remove the override from the array
const currentOverrides = user.activeOverrides || [];
const newOverrides = currentOverrides.filter((o) => o !== overrideToRemove);
// Update the user record in PocketBase
const pb = createPocketBaseClient();
await pb
.collection("users")
.update(user.id, { activeOverrides: newOverrides });
return NextResponse.json({ activeOverrides: newOverrides });
});

View File

@@ -13,11 +13,16 @@ import {
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyData, DailyLog } from "@/types";
import type { DailyData, DailyLog, HrvStatus } from "@/types";
// Default biometrics when no Garmin data is available
const DEFAULT_BIOMETRICS = {
hrvStatus: "Unknown" as const,
const DEFAULT_BIOMETRICS: {
hrvStatus: HrvStatus;
bodyBatteryCurrent: number;
bodyBatteryYesterdayLow: number;
weekIntensityMinutes: number;
} = {
hrvStatus: "Unknown",
bodyBatteryCurrent: 100,
bodyBatteryYesterdayLow: 100,
weekIntensityMinutes: 0,
@@ -69,8 +74,11 @@ export const GET = withAuth(async (_request, user) => {
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
bodyBatteryCurrent:
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
bodyBatteryYesterdayLow:
dailyLog.bodyBatteryYesterdayLow ??
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};