Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion backend/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

});

Expand Down Expand Up @@ -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);
});
});

6 changes: 5 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
Expand Down
70 changes: 70 additions & 0 deletions backend/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

Expand Down
24 changes: 24 additions & 0 deletions backend/src/services/eventHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export function getGlobalEvents(
cursor?: number,
streamId?: string,
since?: number,
actor?: string,
to?: number,
): StreamEvent[] {
const db = getDb();
const conditions: string[] = [];
Expand All @@ -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 ");
Expand All @@ -154,6 +166,8 @@ export function countAllEvents(
eventType?: StreamEventType,
streamId?: string,
since?: number,
actor?: string,
to?: number,
): number {
const db = getDb();
const conditions: string[] = [];
Expand All @@ -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 ");
Expand Down
10 changes: 10 additions & 0 deletions backend/src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down