diff --git a/app/api/routes-f/events/__tests__/buffer.test.ts b/app/api/routes-f/events/__tests__/buffer.test.ts new file mode 100644 index 00000000..b4ae79b7 --- /dev/null +++ b/app/api/routes-f/events/__tests__/buffer.test.ts @@ -0,0 +1,138 @@ +import { EventBufferManager } from "../_lib/buffer"; + +describe("EventBufferManager", () => { + let buffer: EventBufferManager; + + beforeEach(() => { + buffer = new EventBufferManager(); + }); + + describe("addEvents", () => { + it("should add events to buffer", () => { + const events = [ + { name: "test1", timestamp: Date.now() }, + { name: "test2", timestamp: Date.now() + 1 }, + ]; + + buffer.addEvents(events); + expect(buffer.getBufferSize()).toBe(2); + }); + + it("should handle buffer overflow by removing oldest events", () => { + // Fill buffer to capacity + const events = Array.from({ length: 10000 }, (_, i) => ({ + name: `event${i}`, + timestamp: Date.now() + i, + })); + + buffer.addEvents(events); + expect(buffer.getBufferSize()).toBe(10000); + + // Add more events to trigger overflow + const newEvents = [ + { name: "new1", timestamp: Date.now() + 10001 }, + { name: "new2", timestamp: Date.now() + 10002 }, + ]; + + buffer.addEvents(newEvents); + expect(buffer.getBufferSize()).toBe(10000); + + // Verify oldest events were removed + const result = buffer.getEvents({ page: 1, limit: 10000 }); + expect(result.events).toHaveLength(10000); + expect(result.events[0].name).toBe("event2"); // event0 should be removed + expect(result.events[9999].name).toBe("new2"); + }); + + it("should handle single event addition", () => { + const event = { name: "single", timestamp: Date.now() }; + buffer.addEvents([event]); + expect(buffer.getBufferSize()).toBe(1); + }); + }); + + describe("getEvents", () => { + beforeEach(() => { + // Add test events + const events = Array.from({ length: 10 }, (_, i) => ({ + name: `event${i}`, + timestamp: Date.now() + i, + })); + buffer.addEvents(events); + }); + + it("should return events sorted by timestamp (newest first)", () => { + const result = buffer.getEvents({ page: 1, limit: 5 }); + expect(result.events).toHaveLength(5); + expect(result.events[0].name).toBe("event9"); // newest + expect(result.events[4].name).toBe("event5"); + }); + + it("should handle pagination correctly", () => { + const page1 = buffer.getEvents({ page: 1, limit: 3 }); + const page2 = buffer.getEvents({ page: 2, limit: 3 }); + + expect(page1.events).toHaveLength(3); + expect(page2.events).toHaveLength(3); + expect(page1.events[0].name).toBe("event9"); + expect(page2.events[0].name).toBe("event6"); + + expect(page1.pagination.page).toBe(1); + expect(page1.pagination.hasNext).toBe(true); + expect(page1.pagination.hasPrev).toBe(false); + + expect(page2.pagination.page).toBe(2); + expect(page2.pagination.hasNext).toBe(true); + expect(page2.pagination.hasPrev).toBe(true); + }); + + it("should handle last page correctly", () => { + const result = buffer.getEvents({ page: 4, limit: 3 }); + expect(result.events).toHaveLength(1); + expect(result.pagination.hasNext).toBe(false); + expect(result.pagination.hasPrev).toBe(true); + }); + + it("should return empty result for page beyond range", () => { + const result = buffer.getEvents({ page: 10, limit: 5 }); + expect(result.events).toHaveLength(0); + expect(result.pagination.hasNext).toBe(false); + expect(result.pagination.hasPrev).toBe(true); + }); + + it("should use default pagination parameters", () => { + const result = buffer.getEvents({}); + expect(result.pagination.page).toBe(1); + expect(result.pagination.limit).toBe(50); + }); + }); + + describe("buffer management", () => { + it("should clear buffer", () => { + buffer.addEvents([{ name: "test", timestamp: Date.now() }]); + expect(buffer.getBufferSize()).toBe(1); + + buffer.clearBuffer(); + expect(buffer.getBufferSize()).toBe(0); + }); + + it("should return correct buffer size", () => { + expect(buffer.getBufferSize()).toBe(0); + buffer.addEvents([{ name: "test", timestamp: Date.now() }]); + expect(buffer.getBufferSize()).toBe(1); + }); + + it("should return buffer statistics", () => { + const events = Array.from({ length: 50 }, (_, i) => ({ + name: `event${i}`, + timestamp: Date.now() + i, + })); + buffer.addEvents(events); + + const stats = buffer.getStats(); + expect(stats.size).toBe(50); + expect(stats.maxSize).toBe(10000); + expect(stats.utilization).toBe(0.5); // 50/10000 + }); + }); +}); diff --git a/app/api/routes-f/events/__tests__/route.test.ts b/app/api/routes-f/events/__tests__/route.test.ts new file mode 100644 index 00000000..05577c31 --- /dev/null +++ b/app/api/routes-f/events/__tests__/route.test.ts @@ -0,0 +1,254 @@ +import { NextRequest } from "next/server"; +import { POST, GET } from "../route"; +import { eventBuffer } from "../_lib/buffer"; + +// Mock the buffer +jest.mock("../_lib/buffer", () => ({ + eventBuffer: { + addEvents: jest.fn(), + getEvents: jest.fn(), + getBufferSize: jest.fn(), + clearBuffer: jest.fn(), + }, +})); + +describe("/api/routes-f/events", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("POST /api/routes-f/events", () => { + it("should accept a single valid event", async () => { + const mockEvent = { + name: "user_login", + timestamp: Date.now(), + properties: { userId: "123" }, + }; + + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ event: mockEvent }), + headers: { "Content-Type": "application/json" }, + }); + + (eventBuffer.addEvents as jest.Mock).mockReturnValue(undefined); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.success).toBe(true); + expect(data.data.received).toBe(1); + expect(eventBuffer.addEvents).toHaveBeenCalledWith([mockEvent]); + }); + + it("should accept a batch of valid events", async () => { + const mockEvents = [ + { name: "user_login", timestamp: Date.now() }, + { name: "page_view", timestamp: Date.now() + 1 }, + ]; + + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ events: mockEvents }), + headers: { "Content-Type": "application/json" }, + }); + + (eventBuffer.addEvents as jest.Mock).mockReturnValue(undefined); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.success).toBe(true); + expect(data.data.received).toBe(2); + expect(eventBuffer.addEvents).toHaveBeenCalledWith(mockEvents); + }); + + it("should reject requests with neither event nor events", async () => { + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain("Either 'event' or 'events' must be provided"); + }); + + it("should reject requests with both event and events", async () => { + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ + event: { name: "test", timestamp: Date.now() }, + events: [{ name: "test", timestamp: Date.now() }], + }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain("Cannot provide both 'event' and 'events'"); + }); + + it("should reject batches larger than 100 events", async () => { + const largeBatch = Array.from({ length: 101 }, (_, i) => ({ + name: "test_event", + timestamp: Date.now() + i, + })); + + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ events: largeBatch }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain("Batch size cannot exceed 100 events"); + }); + + it("should reject events with missing required fields", async () => { + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ + event: { name: "", timestamp: Date.now() }, + }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain("Event name is required"); + }); + + it("should reject events with invalid timestamp", async () => { + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: JSON.stringify({ + event: { name: "test", timestamp: -1 }, + }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain("Timestamp must be a positive integer"); + }); + + it("should handle malformed JSON", async () => { + const request = new NextRequest("http://localhost/api/routes-f/events", { + method: "POST", + body: "invalid json", + headers: { "Content-Type": "application/json" }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("Internal server error"); + }); + }); + + describe("GET /api/routes-f/events", () => { + it("should return paginated events with default parameters", async () => { + const mockEvents = [ + { name: "event1", timestamp: Date.now() }, + { name: "event2", timestamp: Date.now() - 1000 }, + ]; + + const mockResult = { + events: mockEvents, + pagination: { + page: 1, + limit: 50, + total: 2, + hasNext: false, + hasPrev: false, + }, + }; + + (eventBuffer.getEvents as jest.Mock).mockReturnValue(mockResult); + + const request = new NextRequest("http://localhost/api/routes-f/events"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual(mockResult); + expect(eventBuffer.getEvents).toHaveBeenCalledWith({ page: 1, limit: 50 }); + }); + + it("should return paginated events with custom parameters", async () => { + const mockResult = { + events: [], + pagination: { + page: 2, + limit: 25, + total: 100, + hasNext: true, + hasPrev: true, + }, + }; + + (eventBuffer.getEvents as jest.Mock).mockReturnValue(mockResult); + + const request = new NextRequest( + "http://localhost/api/routes-f/events?page=2&limit=25" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual(mockResult); + expect(eventBuffer.getEvents).toHaveBeenCalledWith({ page: 2, limit: 25 }); + }); + + it("should validate pagination parameters", async () => { + const request = new NextRequest( + "http://localhost/api/routes-f/events?page=0&limit=101" + ); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + // Should default to valid values due to schema validation + expect(eventBuffer.getEvents).toHaveBeenCalledWith({ page: 1, limit: 50 }); + }); + + it("should handle errors gracefully", async () => { + (eventBuffer.getEvents as jest.Mock).mockImplementation(() => { + throw new Error("Buffer error"); + }); + + const request = new NextRequest("http://localhost/api/routes-f/events"); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("Internal server error"); + }); + }); +}); diff --git a/app/api/routes-f/events/_lib/buffer.ts b/app/api/routes-f/events/_lib/buffer.ts new file mode 100644 index 00000000..2e02903c --- /dev/null +++ b/app/api/routes-f/events/_lib/buffer.ts @@ -0,0 +1,68 @@ +import { AnalyticsEvent, EventBuffer, PaginatedEvents, PaginationParams } from "./types"; + +const MAX_BUFFER_SIZE = 10000; + +class EventBufferManager { + private buffer: AnalyticsEvent[] = []; + private maxSize: number = MAX_BUFFER_SIZE; + + addEvents(events: AnalyticsEvent[]): void { + const totalEvents = events.length; + + // If adding these events would exceed the buffer, remove oldest events first + if (this.buffer.length + totalEvents > this.maxSize) { + const overflow = this.buffer.length + totalEvents - this.maxSize; + this.buffer.splice(0, overflow); + } + + // Add new events + this.buffer.push(...events); + } + + getEvents(params: PaginationParams): PaginatedEvents { + const page = params.page || 1; + const limit = params.limit || 50; + + // Sort events by timestamp (newest first) + const sortedEvents = [...this.buffer].sort((a, b) => b.timestamp - a.timestamp); + + const total = sortedEvents.length; + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedEvents = sortedEvents.slice(startIndex, endIndex); + + return { + events: paginatedEvents, + pagination: { + page, + limit, + total, + hasNext: endIndex < total, + hasPrev: page > 1, + }, + }; + } + + getBufferSize(): number { + return this.buffer.length; + } + + clearBuffer(): void { + this.buffer = []; + } + + // Get buffer statistics + getStats() { + return { + size: this.buffer.length, + maxSize: this.maxSize, + utilization: (this.buffer.length / this.maxSize) * 100, + }; + } +} + +// Export the class for testing +export { EventBufferManager }; + +// Singleton instance for the entire application +export const eventBuffer = new EventBufferManager(); diff --git a/app/api/routes-f/events/_lib/schema.ts b/app/api/routes-f/events/_lib/schema.ts new file mode 100644 index 00000000..62a02b85 --- /dev/null +++ b/app/api/routes-f/events/_lib/schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { AnalyticsEvent, EventSubmission } from "./types"; + +export const AnalyticsEventSchema: z.ZodType = z.object({ + name: z.string().min(1, "Event name is required"), + timestamp: z.number().int().positive("Timestamp must be a positive integer"), + properties: z.record(z.any()).optional(), +}); + +export const EventSubmissionSchema: z.ZodType = z.object({ + event: AnalyticsEventSchema.optional(), + events: z.array(AnalyticsEventSchema).optional(), +}).refine( + (data) => data.event || data.events, + { + message: "Either 'event' or 'events' must be provided", + path: [], + } +).refine( + (data) => !(data.event && data.events), + { + message: "Cannot provide both 'event' and 'events'", + path: [], + } +).refine( + (data) => !data.events || data.events.length <= 100, + { + message: "Batch size cannot exceed 100 events", + path: ["events"], + } +); + +export const PaginationParamsSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); diff --git a/app/api/routes-f/events/_lib/types.ts b/app/api/routes-f/events/_lib/types.ts new file mode 100644 index 00000000..ffba23bd --- /dev/null +++ b/app/api/routes-f/events/_lib/types.ts @@ -0,0 +1,37 @@ +export interface AnalyticsEvent { + name: string; + timestamp: number; + properties?: Record; +} + +export interface EventSubmission { + event?: AnalyticsEvent; + events?: AnalyticsEvent[]; +} + +export interface EventBuffer { + events: AnalyticsEvent[]; + maxSize: number; +} + +export interface PaginationParams { + page?: number; + limit?: number; +} + +export interface PaginatedEvents { + events: AnalyticsEvent[]; + pagination: { + page: number; + limit: number; + total: number; + hasNext: boolean; + hasPrev: boolean; + }; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} diff --git a/app/api/routes-f/events/route.ts b/app/api/routes-f/events/route.ts new file mode 100644 index 00000000..c745cba0 --- /dev/null +++ b/app/api/routes-f/events/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { EventSubmissionSchema, PaginationParamsSchema } from "./_lib/schema"; +import { eventBuffer } from "./_lib/buffer"; +import { ApiResponse, PaginatedEvents } from "./_lib/types"; + +// POST /api/routes-f/events - Accept single or batched events +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Validate the request body + const validationResult = EventSubmissionSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: "Validation failed: " + validationResult.error.errors + .map((e: any) => `${e.path.join('.')}: ${e.message}`) + .join(", "), + }, + { status: 400 } + ); + } + + const { event, events } = validationResult.data; + + // Add events to buffer + if (event) { + eventBuffer.addEvents([event]); + } else if (events) { + eventBuffer.addEvents(events); + } + + return NextResponse.json( + { success: true, data: { received: event ? 1 : events?.length || 0 } }, + { status: 201 } + ); + } catch (error) { + // Log error for debugging (eslint-disable-next-line no-console) + console.error("[events] POST error:", error); + return NextResponse.json( + { success: false, error: "Internal server error" }, + { status: 500 } + ); + } +} + +// GET /api/routes-f/events - Return recent events with pagination +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + + // Parse pagination parameters + const paginationParams = PaginationParamsSchema.parse({ + page: searchParams.get("page"), + limit: searchParams.get("limit"), + }); + + const result = eventBuffer.getEvents(paginationParams); + + return NextResponse.json>( + { success: true, data: result }, + { status: 200 } + ); + } catch (error) { + // Log error for debugging (eslint-disable-next-line no-console) + console.error("[events] GET error:", error); + return NextResponse.json( + { success: false, error: "Internal server error" }, + { status: 500 } + ); + } +}