From e91202826bdec756cc1729d6a57c10b7ea131d65 Mon Sep 17 00:00:00 2001 From: alexatsejames-alt Date: Fri, 24 Apr 2026 03:43:50 -1200 Subject: [PATCH 1/2] feat(api): expose aggregated event counts per stream via GET /api/streams/:id/history/summary - eventHistory.ts: add StreamEventSummary interface and getStreamEventSummary() using a single GROUP BY SQL query; missing event types default to 0 - index.ts: import getStreamEventSummary, add GET /api/streams/:id/history/summary route with same 404 guard pattern as the existing /history endpoint - swagger.ts: document /api/streams/{id}/history/summary with full response schema matching the acceptance criteria shape - index.test.ts: add getStreamEventSummary to mock hoisting and reset; add 3 tests covering all-counts, all-zeros, and 404-for-unknown-stream cases --- backend/src/index.test.ts | 77 ++++++++++++++++++++++++++++ backend/src/index.ts | 17 ++++++ backend/src/services/eventHistory.ts | 34 ++++++++++++ backend/src/swagger.ts | 47 +++++++++++++++++ 4 files changed, 175 insertions(+) diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index 2211454..669fd70 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -17,6 +17,7 @@ const eventHistoryMocks = vi.hoisted(() => ({ getGlobalEvents: vi.fn(), countAllEvents: vi.fn(), recordEvent: vi.fn(), + getStreamEventSummary: vi.fn(), })); vi.mock("./services/streamStore", () => streamStoreMocks); @@ -214,6 +215,7 @@ beforeEach(() => { eventHistoryMocks.getGlobalEvents.mockReset(); eventHistoryMocks.countAllEvents.mockReset(); eventHistoryMocks.getStreamHistory.mockReset(); + eventHistoryMocks.getStreamEventSummary.mockReset(); }); describe("GET /api/streams", () => { @@ -571,3 +573,78 @@ describe("GET /api/events", () => { }); }); + +describe("GET /api/streams/:id/history/summary", () => { + const mockStream = { + id: "stream-1", + sender: SENDER_A, + recipient: RECIPIENT_1, + assetCode: "USDC", + totalAmount: 1000, + durationSeconds: 3600, + startAt: 1000, + createdAt: 900, + }; + + beforeEach(() => { + streamStoreMocks.getStream.mockReturnValue(mockStream); + streamStoreMocks.calculateProgress.mockReturnValue({ + status: "active", + ratePerSecond: 0.27, + elapsedSeconds: 100, + vestedAmount: 27, + remainingAmount: 973, + percentComplete: 2.7, + }); + }); + + it("returns counts for all event types", () => { + eventHistoryMocks.getStreamEventSummary.mockReturnValue({ + created: 1, + claimed: 3, + canceled: 0, + start_time_updated: 1, + }); + + const res = request(app).get("/api/streams/stream-1/history/summary"); + return res.then(({ status, body }: { status: number; body: any }) => { + expect(status).toBe(200); + expect(body.data).toEqual({ + created: 1, + claimed: 3, + canceled: 0, + start_time_updated: 1, + }); + }); + }); + + it("returns all zeros when stream has no events", () => { + eventHistoryMocks.getStreamEventSummary.mockReturnValue({ + created: 0, + claimed: 0, + canceled: 0, + start_time_updated: 0, + }); + + const res = request(app).get("/api/streams/stream-1/history/summary"); + return res.then(({ status, body }: { status: number; body: any }) => { + expect(status).toBe(200); + expect(body.data).toEqual({ + created: 0, + claimed: 0, + canceled: 0, + start_time_updated: 0, + }); + }); + }); + + it("returns 404 when stream does not exist", () => { + streamStoreMocks.getStream.mockReturnValue(undefined); + + const res = request(app).get("/api/streams/nonexistent/history/summary"); + return res.then(({ status, body }: { status: number; body: any }) => { + expect(status).toBe(404); + expect(body.error).toBeDefined(); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index b4de273..c9d3d3f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,7 @@ import { getAllEvents, getGlobalEvents, getStreamHistory, + getStreamEventSummary, } from "./services/eventHistory"; import { fetchOpenIssues } from "./services/openIssues"; import { initIndexer, startIndexer } from "./services/indexer"; @@ -617,6 +618,22 @@ app.get("/api/streams/:id/history", (req: Request, res: Response) => { res.json({ data: getStreamHistory(parsedId.value) }); }); +app.get("/api/streams/:id/history/summary", (req: Request, res: Response) => { + const parsedId = parseStreamId(req.params.id); + if (!parsedId.ok) { + sendValidationError(req, res, parsedId.issues); + return; + } + + const stream = getStream(parsedId.value); + if (!stream) { + sendApiError(req, res, 404, "Stream not found.", { code: "NOT_FOUND" }); + return; + } + + res.json({ data: getStreamEventSummary(parsedId.value) }); +}); + app.get("/api/streams/:id/snapshot", (req: Request, res: Response) => { const parsedId = parseStreamId(req.params.id); if (!parsedId.ok) { diff --git a/backend/src/services/eventHistory.ts b/backend/src/services/eventHistory.ts index 5c1004c..3b37bf5 100644 --- a/backend/src/services/eventHistory.ts +++ b/backend/src/services/eventHistory.ts @@ -124,6 +124,40 @@ export function countAllEvents(eventType?: StreamEventType): number { return row.count; } +export interface StreamEventSummary { + created: number; + claimed: number; + canceled: number; + start_time_updated: number; +} + +export function getStreamEventSummary(streamId: string): StreamEventSummary { + const db = getDb(); + const rows = db + .prepare( + `SELECT event_type, COUNT(*) as count + FROM stream_events + WHERE stream_id = ? + GROUP BY event_type`, + ) + .all(streamId) as { event_type: string; count: number }[]; + + const summary: StreamEventSummary = { + created: 0, + claimed: 0, + canceled: 0, + start_time_updated: 0, + }; + + for (const row of rows) { + if (row.event_type in summary) { + summary[row.event_type as StreamEventType] = row.count; + } + } + + return summary; +} + export function streamHasEvent( streamId: string, eventType: StreamEventType, diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 5ad8d86..58ca101 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -875,6 +875,53 @@ export const swaggerDocument = { }, }, }, + "/api/streams/{id}/history/summary": { + get: { + summary: "Get stream event count summary", + description: "Returns aggregated event counts per type for a stream. Useful for dashboard badges. Uses a single GROUP BY query; missing event types return 0.", + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "The unique ID of the stream.", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Event count summary.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { + type: "object", + required: ["created", "claimed", "canceled", "start_time_updated"], + properties: { + created: { type: "integer", example: 1 }, + claimed: { type: "integer", example: 3 }, + canceled: { type: "integer", example: 0 }, + start_time_updated: { type: "integer", example: 1 }, + }, + }, + }, + }, + }, + }, + }, + "404": { + description: "Stream not found.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + }, + }, "/api/streams/{id}/snapshot": { get: { summary: "Get Stream Snapshot", From 9e951f6894699674b0f8d912c295540d5fcb6e90 Mon Sep 17 00:00:00 2001 From: alexatsejames-alt Date: Sat, 25 Apr 2026 05:49:02 -1200 Subject: [PATCH 2/2] refactor(tests): use invokeStreamSummaryRoute helper for history/summary tests - Add invokeStreamSummaryRoute(id) helper consistent with invokeListStreamsRoute pattern - Replace request(app).get() calls with direct route invocation in all three summary tests - Remove conflict markers and fix broken indentation/extra closing braces - Preserve all existing /api/streams and /api/events tests intact --- backend/src/index.test.ts | 76 ++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index 669fd70..1ed8e70 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -570,9 +570,38 @@ describe("GET /api/events", () => { }); }); +function invokeStreamSummaryRoute(id: string): { status: number; body: any } { + const layer = (app as any)?._router?.stack?.find( + (entry: any) => + entry.route?.path === "/api/streams/:id/history/summary" && + entry.route?.methods?.get, + ); - }); -}); + if (!layer) { + throw new Error("GET /api/streams/:id/history/summary route not found"); + } + + const handler = layer.route.stack[0].handle as (req: any, res: any) => void; + + let statusCode = 200; + let jsonBody: any; + + const req = { params: { id }, requestId: "test-request-id" }; + const res = { + status(code: number) { + statusCode = code; + return this; + }, + json(payload: any) { + jsonBody = payload; + return this; + }, + }; + + handler(req, res); + + return { status: statusCode, body: jsonBody }; +} describe("GET /api/streams/:id/history/summary", () => { const mockStream = { @@ -606,15 +635,14 @@ describe("GET /api/streams/:id/history/summary", () => { start_time_updated: 1, }); - const res = request(app).get("/api/streams/stream-1/history/summary"); - return res.then(({ status, body }: { status: number; body: any }) => { - expect(status).toBe(200); - expect(body.data).toEqual({ - created: 1, - claimed: 3, - canceled: 0, - start_time_updated: 1, - }); + const { status, body } = invokeStreamSummaryRoute("stream-1"); + + expect(status).toBe(200); + expect(body.data).toEqual({ + created: 1, + claimed: 3, + canceled: 0, + start_time_updated: 1, }); }); @@ -626,25 +654,23 @@ describe("GET /api/streams/:id/history/summary", () => { start_time_updated: 0, }); - const res = request(app).get("/api/streams/stream-1/history/summary"); - return res.then(({ status, body }: { status: number; body: any }) => { - expect(status).toBe(200); - expect(body.data).toEqual({ - created: 0, - claimed: 0, - canceled: 0, - start_time_updated: 0, - }); + const { status, body } = invokeStreamSummaryRoute("stream-1"); + + expect(status).toBe(200); + expect(body.data).toEqual({ + created: 0, + claimed: 0, + canceled: 0, + start_time_updated: 0, }); }); it("returns 404 when stream does not exist", () => { streamStoreMocks.getStream.mockReturnValue(undefined); - const res = request(app).get("/api/streams/nonexistent/history/summary"); - return res.then(({ status, body }: { status: number; body: any }) => { - expect(status).toBe(404); - expect(body.error).toBeDefined(); - }); + const { status, body } = invokeStreamSummaryRoute("nonexistent"); + + expect(status).toBe(404); + expect(body.error).toBeDefined(); }); });