diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index d0737aab..2d8f65dc 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -36,9 +36,11 @@ vi.mock("./services/streamStore", () => streamStoreMocks); vi.mock("./services/eventHistory", () => eventHistoryMocks); vi.mock("./services/auth", () => ({ authMiddleware: vi.fn((req: any, res: any, next: any) => next()), + adminJwtAuth: vi.fn((req: any, res: any, next: any) => next()), generateChallenge: vi.fn(), refreshToken: vi.fn(), verifyChallengeAndIssueToken: vi.fn(), + getJwtSecret: vi.fn(() => "test_secret"), })); const TEST_JWT_SECRET = "test_secret_for_integration"; @@ -686,7 +688,7 @@ describe("GET /api/events", () => { expect(status).toBe(200); expect(body.total).toBe(2); - expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith("created"); + expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith("created", undefined, undefined, undefined, undefined); }); @@ -782,5 +784,88 @@ describe("GET /api/events", () => { amount: 50, }); }); + + it("filters by actor – passes it to getGlobalEvents and countAllEvents", () => { + const actorEvents = sampleEvents.filter((e) => e.actor === "GSENDER"); + eventHistoryMocks.countAllEvents.mockReturnValue(actorEvents.length); + eventHistoryMocks.getGlobalEvents.mockReturnValue(actorEvents); + + const { status, body } = invokeGlobalEventsRoute({ actor: "GSENDER" }); + + expect(status).toBe(200); + expect(body.total).toBe(4); + expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith( + undefined, undefined, undefined, "GSENDER", undefined, + ); + expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith( + expect.any(Number), expect.any(Number), undefined, undefined, undefined, undefined, "GSENDER", undefined, + ); + }); + + it("filters by to timestamp – passes it to getGlobalEvents and countAllEvents", () => { + const toEvents = sampleEvents.filter((e) => e.timestamp <= 300); + eventHistoryMocks.countAllEvents.mockReturnValue(toEvents.length); + eventHistoryMocks.getGlobalEvents.mockReturnValue(toEvents); + + const { status, body } = invokeGlobalEventsRoute({ to: "300" }); + + expect(status).toBe(200); + expect(body.total).toBe(3); + expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith( + undefined, undefined, undefined, undefined, 300, + ); + expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith( + expect.any(Number), expect.any(Number), undefined, undefined, undefined, undefined, undefined, 300, + ); + }); + + it("combines actor and to filters", () => { + const filteredEvents = sampleEvents.filter( + (e) => e.actor === "GSENDER" && e.timestamp <= 300, + ); + eventHistoryMocks.countAllEvents.mockReturnValue(filteredEvents.length); + eventHistoryMocks.getGlobalEvents.mockReturnValue(filteredEvents); + + const { status, body } = invokeGlobalEventsRoute({ actor: "GSENDER", to: "300" }); + + expect(status).toBe(200); + expect(body.total).toBe(3); + expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith( + undefined, undefined, undefined, "GSENDER", 300, + ); + }); + + it("combines all filters: eventType, streamId, actor, since, and to", () => { + eventHistoryMocks.countAllEvents.mockReturnValue(1); + eventHistoryMocks.getGlobalEvents.mockReturnValue([sampleEvents[0]]); + + const { status, body } = invokeGlobalEventsRoute({ + eventType: "claimed", + streamId: "stream-1", + actor: "GSENDER", + since: "350", + to: "450", + }); + + expect(status).toBe(200); + expect(body.total).toBe(1); + expect(eventHistoryMocks.countAllEvents).toHaveBeenCalledWith( + "claimed", "stream-1", 350, "GSENDER", 450, + ); + expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith( + expect.any(Number), expect.any(Number), "claimed", undefined, "stream-1", 350, "GSENDER", 450, + ); + }); + + it("returns empty result for no matches with correct metadata", () => { + eventHistoryMocks.countAllEvents.mockReturnValue(0); + eventHistoryMocks.getGlobalEvents.mockReturnValue([]); + + const { status, body } = invokeGlobalEventsRoute({ actor: "GNONEXISTENT" }); + + expect(status).toBe(200); + expect(body.data).toEqual([]); + expect(body.total).toBe(0); + }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 4ff0cfdc..fe7caab4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -660,8 +660,10 @@ app.get("/api/events", readLimiter, (req: Request, res: Response) => { const eventType = query.eventType as StreamEventType | undefined; const streamId = query.streamId; const since = query.since; + const actor = query.actor; + const to = query.to; - const total = countAllEvents(eventType, streamId, since); + const total = countAllEvents(eventType, streamId, since, actor, to); const page = query.page ?? PAGINATION_DEFAULT_PAGE; const pageSize = query.pageSize ?? query.limit ?? PAGINATION_DEFAULT_LIMIT; @@ -674,6 +676,8 @@ app.get("/api/events", readLimiter, (req: Request, res: Response) => { query.cursor, streamId, since, + actor, + to, ); res.json({ data, total, page, pageSize, limit: pageSize }); diff --git a/backend/src/integration.test.ts b/backend/src/integration.test.ts index b53a96a8..90a9dd77 100644 --- a/backend/src/integration.test.ts +++ b/backend/src/integration.test.ts @@ -1747,6 +1747,76 @@ describe("Backend Integration Tests", () => { expect(response.body.page).toBe(1); expect(response.body.pageSize).toBe(2); }); + + it("should filter by actor", async () => { + const response = await request(app) + .get("/api/events") + .query({ actor: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(4); + expect(response.body.total).toBe(4); + response.body.data.forEach((e: any) => { + expect(e.actor).toBe("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + }); + }); + + it("should filter by to timestamp", async () => { + const now = Math.floor(Date.now() / 1000); + // Events were inserted at now+1, now+2, now+3 (created) and now+100 (canceled) + // Filtering to now+50 should only return the 3 created events + const response = await request(app) + .get("/api/events") + .query({ to: now + 50 }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data.every((e: any) => e.eventType === "created")).toBe(true); + }); + + it("should combine actor and to filters", async () => { + const now = Math.floor(Date.now() / 1000); + // Only the canceled event has timestamp > now+50 + const response = await request(app) + .get("/api/events") + .query({ actor: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", to: now + 50 }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(3); + response.body.data.forEach((e: any) => { + expect(e.actor).toBe("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + expect(e.timestamp).toBeLessThanOrEqual(now + 50); + }); + }); + + it("should combine all filters: eventType, streamId, actor, since, and to", async () => { + const now = Math.floor(Date.now() / 1000); + const response = await request(app) + .get("/api/events") + .query({ + eventType: "created", + streamId: "1", + actor: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + since: now, + to: now + 50, + }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.total).toBe(1); + expect(response.body.data[0].eventType).toBe("created"); + expect(response.body.data[0].streamId).toBe("1"); + }); + + it("should return empty result for non-existent actor", async () => { + const response = await request(app) + .get("/api/events") + .query({ actor: "GNONEXISTENT" }); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); }); }); diff --git a/backend/src/services/eventHistory.ts b/backend/src/services/eventHistory.ts index 0dc429b9..28cfd331 100644 --- a/backend/src/services/eventHistory.ts +++ b/backend/src/services/eventHistory.ts @@ -114,6 +114,8 @@ export function getGlobalEvents( cursor?: number, streamId?: string, since?: number, + actor?: string, + to?: number, ): StreamEvent[] { const db = getDb(); const conditions: string[] = []; @@ -139,6 +141,16 @@ export function getGlobalEvents( params.push(since); } + if (to !== undefined) { + conditions.push("timestamp <= ?"); + params.push(to); + } + + if (actor) { + conditions.push("actor = ?"); + params.push(actor); + } + let query = "SELECT * FROM stream_events"; if (conditions.length > 0) { query += " WHERE " + conditions.join(" AND "); @@ -154,6 +166,8 @@ export function countAllEvents( eventType?: StreamEventType, streamId?: string, since?: number, + actor?: string, + to?: number, ): number { const db = getDb(); const conditions: string[] = []; @@ -174,6 +188,16 @@ export function countAllEvents( params.push(since); } + if (to !== undefined) { + conditions.push("timestamp <= ?"); + params.push(to); + } + + if (actor) { + conditions.push("actor = ?"); + params.push(actor); + } + let query = "SELECT COUNT(*) as count FROM stream_events"; if (conditions.length > 0) { query += " WHERE " + conditions.join(" AND "); diff --git a/backend/src/validation/schemas.ts b/backend/src/validation/schemas.ts index cd03cffb..663a12e0 100644 --- a/backend/src/validation/schemas.ts +++ b/backend/src/validation/schemas.ts @@ -190,11 +190,21 @@ export const listEventsQuerySchema = z.object({ .trim() .min(1, "streamId must not be empty if provided") .optional(), + actor: z + .string() + .trim() + .min(1, "actor must not be empty if provided") + .optional(), since: z .coerce.number() .int("since must be an integer") .positive("since must be a positive unix timestamp") .optional(), + to: z + .coerce.number() + .int("to must be an integer") + .positive("to must be a positive unix timestamp") + .optional(), cursor: z .coerce.number() .int("cursor must be an integer")