Implement calendar ICS feed and token regeneration (P2.6, P2.7)
Add two calendar-related API endpoints: P2.6 - GET /api/calendar/[userId]/[token].ics: - Token-based authentication (no session required) - Validates calendar token against user record - Generates 90 days of phase events using generateIcsFeed() - Returns proper Content-Type and Cache-Control headers - 404 for non-existent users, 401 for invalid tokens - 10 tests covering all scenarios P2.7 - POST /api/calendar/regenerate-token: - Requires authentication via withAuth() middleware - Generates cryptographically secure 32-character hex token - Updates user's calendarToken field in database - Returns new token and formatted calendar URL - Old tokens immediately invalidated - 9 tests covering token generation and auth Total: 19 new tests, 360 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
|
||||
| DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 tests) |
|
||||
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
|
||||
| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO |
|
||||
| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented |
|
||||
| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) |
|
||||
| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) |
|
||||
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) |
|
||||
| POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) |
|
||||
|
||||
@@ -85,6 +85,9 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
|
||||
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
|
||||
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) |
|
||||
| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) |
|
||||
| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) |
|
||||
| `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) |
|
||||
| E2E tests | **NONE** |
|
||||
|
||||
### Critical Business Rules (from Spec)
|
||||
@@ -324,21 +327,33 @@ Full feature set for production use.
|
||||
- **Why:** Email notifications are a key feature per spec
|
||||
- **Depends On:** P2.4
|
||||
|
||||
### P2.6: GET /api/calendar/[userId]/[token].ics Implementation
|
||||
- [ ] Return ICS feed for calendar subscription
|
||||
### P2.6: GET /api/calendar/[userId]/[token].ics Implementation ✅ COMPLETE
|
||||
- [x] Return ICS feed for calendar subscription
|
||||
- **Files:**
|
||||
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validate token, generate ICS
|
||||
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validates token, generates ICS with 90 days of phase events
|
||||
- **Tests:**
|
||||
- Integration test: valid token returns ICS, invalid returns 401
|
||||
- `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 10 tests covering token validation, ICS generation, caching headers, error handling
|
||||
- **Features Implemented:**
|
||||
- Token-based authentication (no session required)
|
||||
- Validates calendar token against user record
|
||||
- Generates 90 days of phase events using `generateIcsFeed()`
|
||||
- Returns proper Content-Type header (`text/calendar; charset=utf-8`)
|
||||
- Caching headers for calendar client optimization
|
||||
- 404 for non-existent users, 401 for invalid tokens
|
||||
- **Why:** Calendar integration for external apps
|
||||
- **Note:** Route has param extraction, needs ICS generation (90 days of events per spec)
|
||||
|
||||
### P2.7: POST /api/calendar/regenerate-token Implementation
|
||||
- [ ] Generate new calendar token
|
||||
### P2.7: POST /api/calendar/regenerate-token Implementation ✅ COMPLETE
|
||||
- [x] Generate new calendar token
|
||||
- **Files:**
|
||||
- `src/app/api/calendar/regenerate-token/route.ts` - Create random token, update user
|
||||
- `src/app/api/calendar/regenerate-token/route.ts` - Creates random 32-char token, updates user
|
||||
- **Tests:**
|
||||
- `src/app/api/calendar/regenerate-token/route.test.ts` - Test token uniqueness, old URL invalidation
|
||||
- `src/app/api/calendar/regenerate-token/route.test.ts` - 9 tests covering token generation, URL formatting, auth
|
||||
- **Features Implemented:**
|
||||
- Requires authentication via `withAuth()` middleware
|
||||
- Generates cryptographically secure 32-character hex token
|
||||
- Updates user's `calendarToken` field in database
|
||||
- Returns new token and formatted calendar URL
|
||||
- Old tokens immediately invalidated
|
||||
- **Why:** Security feature for calendar URLs
|
||||
- **Depends On:** P0.1, P0.2
|
||||
|
||||
@@ -584,6 +599,8 @@ P2.14 Mini calendar
|
||||
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
|
||||
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4)
|
||||
- [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5)
|
||||
- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6)
|
||||
- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7)
|
||||
|
||||
### Pages
|
||||
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
||||
|
||||
231
src/app/api/calendar/[userId]/[token].ics/route.test.ts
Normal file
231
src/app/api/calendar/[userId]/[token].ics/route.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// ABOUTME: Unit tests for ICS calendar feed API route.
|
||||
// ABOUTME: Tests GET /api/calendar/[userId]/[token].ics for token validation and ICS generation.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/types";
|
||||
|
||||
// Module-level variable to control mock user lookup
|
||||
let mockUsers: Map<string, User> = new Map();
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
collection: vi.fn(() => ({
|
||||
getOne: vi.fn((userId: string) => {
|
||||
const user = mockUsers.get(userId);
|
||||
if (!user) {
|
||||
const error = new Error("Not found");
|
||||
(error as unknown as { status: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
calendarToken: user.calendarToken,
|
||||
lastPeriodDate: user.lastPeriodDate.toISOString(),
|
||||
cycleLength: user.cycleLength,
|
||||
garminConnected: user.garminConnected,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock ICS generation
|
||||
const mockGenerateIcsFeed = vi.fn().mockReturnValue(`BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//PhaseFlow//EN
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:🔵 MENSTRUAL
|
||||
DTSTART:20250101
|
||||
DTEND:20250105
|
||||
END:VEVENT
|
||||
END:VCALENDAR`);
|
||||
|
||||
vi.mock("@/lib/ics", () => ({
|
||||
generateIcsFeed: (options: { lastPeriodDate: Date; cycleLength: number }) =>
|
||||
mockGenerateIcsFeed(options),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
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: "valid-calendar-token-abc123def",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUsers = new Map();
|
||||
mockUsers.set("user123", mockUser);
|
||||
});
|
||||
|
||||
// Helper to create route context with params
|
||||
function createRouteContext(userId: string, token: string) {
|
||||
return {
|
||||
params: Promise.resolve({ userId, token }),
|
||||
};
|
||||
}
|
||||
|
||||
it("returns 401 for invalid token", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext("user123", "wrong-token");
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent user", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext("nonexistent", "some-token");
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 401 when user has no calendar token set", async () => {
|
||||
mockUsers.set("user456", {
|
||||
...mockUser,
|
||||
id: "user456",
|
||||
calendarToken: "",
|
||||
});
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext("user456", "any-token");
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns ICS content for valid token", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("BEGIN:VCALENDAR");
|
||||
expect(text).toContain("END:VCALENDAR");
|
||||
});
|
||||
|
||||
it("returns correct Content-Type header", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.headers.get("Content-Type")).toBe(
|
||||
"text/calendar; charset=utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls generateIcsFeed with correct parameters", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
await GET(mockRequest, context);
|
||||
|
||||
expect(mockGenerateIcsFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lastPeriodDate: expect.any(Date),
|
||||
cycleLength: 28,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("generates 90 days of events (monthsAhead = 3)", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
await GET(mockRequest, context);
|
||||
|
||||
expect(mockGenerateIcsFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
monthsAhead: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles different cycle lengths", async () => {
|
||||
mockUsers.set("user789", {
|
||||
...mockUser,
|
||||
id: "user789",
|
||||
calendarToken: "token789",
|
||||
cycleLength: 35,
|
||||
});
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext("user789", "token789");
|
||||
|
||||
await GET(mockRequest, context);
|
||||
|
||||
expect(mockGenerateIcsFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cycleLength: 35,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Cache-Control header for caching", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
// Calendar feeds should be cacheable but refresh periodically
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
expect(cacheControl).toBeDefined();
|
||||
expect(cacheControl).toContain("max-age");
|
||||
});
|
||||
|
||||
it("is case-sensitive for token matching", async () => {
|
||||
const mockRequest = {} as NextRequest;
|
||||
// Token with different case should fail
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"VALID-CALENDAR-TOKEN-ABC123DEF",
|
||||
);
|
||||
|
||||
const response = await GET(mockRequest, context);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@
|
||||
// ABOUTME: Returns subscribable iCal feed with cycle phases and warnings.
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { generateIcsFeed } from "@/lib/ics";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
@@ -11,13 +14,58 @@ interface RouteParams {
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { userId, token } = await params;
|
||||
void token; // Token will be used for validation
|
||||
// TODO: Implement ICS feed generation
|
||||
// Validate token, generate ICS content, return with correct headers
|
||||
return new NextResponse(`ICS feed for user ${userId} not implemented`, {
|
||||
status: 501,
|
||||
|
||||
try {
|
||||
// Fetch user from database
|
||||
const pb = createPocketBaseClient();
|
||||
const user = await pb.collection("users").getOne(userId);
|
||||
|
||||
// Check if user has a calendar token set
|
||||
if (!user.calendarToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized: Calendar not configured" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate token (case-sensitive comparison)
|
||||
if (user.calendarToken !== token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized: Invalid token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate ICS feed with 90 days of events (3 months)
|
||||
const icsContent = generateIcsFeed({
|
||||
lastPeriodDate: new Date(user.lastPeriodDate as string),
|
||||
cycleLength: user.cycleLength as number,
|
||||
monthsAhead: 3,
|
||||
});
|
||||
|
||||
// Return ICS content with appropriate headers
|
||||
return new NextResponse(icsContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
|
||||
"Content-Disposition": "attachment; filename=phaseflow-calendar.ics",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Check if error is a "not found" error from PocketBase
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error as unknown as { status?: number }).status === 404
|
||||
) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors
|
||||
console.error("Calendar feed error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
177
src/app/api/calendar/regenerate-token/route.test.ts
Normal file
177
src/app/api/calendar/regenerate-token/route.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// ABOUTME: Unit tests for calendar token regeneration API route.
|
||||
// ABOUTME: Tests POST /api/calendar/regenerate-token for creating new calendar tokens.
|
||||
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({});
|
||||
|
||||
// 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) => {
|
||||
return async (request: NextRequest) => {
|
||||
if (!currentMockUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(request, currentMockUser);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
describe("POST /api/calendar/regenerate-token", () => {
|
||||
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: "old-calendar-token-abc123",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
currentMockUser = null;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("generates a new random token", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.token).toBeDefined();
|
||||
expect(typeof body.token).toBe("string");
|
||||
expect(body.token.length).toBe(32);
|
||||
});
|
||||
|
||||
it("generates different tokens on multiple calls", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response1 = await POST(mockRequest);
|
||||
const body1 = await response1.json();
|
||||
|
||||
const response2 = await POST(mockRequest);
|
||||
const body2 = await response2.json();
|
||||
|
||||
expect(body1.token).not.toBe(body2.token);
|
||||
});
|
||||
|
||||
it("updates user record with new token", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPbUpdate).toHaveBeenCalledWith(
|
||||
"user123",
|
||||
expect.objectContaining({
|
||||
calendarToken: expect.any(String),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the token in the update matches the response
|
||||
const body = await response.json();
|
||||
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
||||
calendarToken: body.token,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the calendar URL in response", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.url).toBeDefined();
|
||||
expect(body.url).toContain("/api/calendar/user123/");
|
||||
expect(body.url).toContain(".ics");
|
||||
});
|
||||
|
||||
it("URL contains the new token", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.url).toContain(body.token);
|
||||
});
|
||||
|
||||
it("generates URL-safe tokens (alphanumeric only)", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
const body = await response.json();
|
||||
// Token should be alphanumeric (URL-safe)
|
||||
expect(body.token).toMatch(/^[a-zA-Z0-9]+$/);
|
||||
});
|
||||
|
||||
it("works when user has no existing calendar token", async () => {
|
||||
currentMockUser = {
|
||||
...mockUser,
|
||||
calendarToken: "",
|
||||
};
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token.length).toBe(32);
|
||||
});
|
||||
|
||||
it("returns success true in response", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,39 @@
|
||||
// ABOUTME: API route for regenerating calendar subscription token.
|
||||
// ABOUTME: Creates new random token, invalidating old calendar URLs.
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement token regeneration
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
import { withAuth } from "@/lib/auth-middleware";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random 32-character alphanumeric token.
|
||||
*/
|
||||
function generateToken(): string {
|
||||
// Generate enough random bytes to ensure we get 32 alphanumeric characters
|
||||
// after base64 encoding and filtering. Generate extra to account for
|
||||
// characters that might be filtered out.
|
||||
return randomBytes(32).toString("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
export const POST = withAuth(async (_request, user) => {
|
||||
// Generate new random token
|
||||
const newToken = generateToken();
|
||||
|
||||
// Update user record with new token
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection("users").update(user.id, {
|
||||
calendarToken: newToken,
|
||||
});
|
||||
|
||||
// Build the calendar URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://phaseflow.app";
|
||||
const url = `${baseUrl}/api/calendar/${user.id}/${newToken}.ics`;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
token: newToken,
|
||||
url,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user