diff --git a/backend/TESTING.md b/backend/TESTING.md index 7ea2d44..ac015fa 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -75,7 +75,8 @@ npm test -- --coverage ### 3. Stream History #### GET /api/streams/:id/history -- ✅ Get event history for stream +- ✅ Get event history for stream (paginated via `page`/`limit`, default limit 50, ascending order) +- ✅ 400 for invalid `page`/`limit` params (limit max 100) - ✅ 404 for non-existent stream #### GET /api/streams/:id/snapshot diff --git a/backend/src/index.ts b/backend/src/index.ts index 4ff0cfd..4a1980e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -111,7 +111,6 @@ const PAGINATION_DEFAULT_PAGE = 1; const PAGINATION_DEFAULT_LIMIT = 20; const PAGINATION_MAX_LIMIT = 100; const STREAM_HISTORY_DEFAULT_LIMIT = 50; -const STREAM_HISTORY_MAX_LIMIT = 200; export const app = express(); const port = Number(process.env.PORT ?? 3001); @@ -1689,25 +1688,29 @@ app.get( return; } + // Parse and validate query parameters + const parsedQuery = listEventsQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + sendValidationError(req, res, parsedQuery.error.issues); + return; + } + const stream = getStream(parsedId.value); if (!stream) { sendApiError(req, res, 404, "Stream not found.", { code: "NOT_FOUND" }); return; } - // Parse and validate query parameters - const page = Math.max(1, parseInt(req.query.page as string) || 1); - const pageSize = Math.min( - Math.max(1, parseInt(req.query.pageSize as string) || 20), - 100, - ); + const query = parsedQuery.data; + const page = query.page ?? PAGINATION_DEFAULT_PAGE; + const limit = query.limit ?? query.pageSize ?? STREAM_HISTORY_DEFAULT_LIMIT; const total = countStreamEvents(parsedId.value); - const offset = (page - 1) * pageSize; - const data = getStreamHistory(parsedId.value, pageSize, offset); - const hasMore = offset + pageSize < total; + const offset = (page - 1) * limit; + // History is served oldest-first (ascending timestamp order). + const data = getStreamHistory(parsedId.value, limit, offset, 'asc'); - res.json({ data, total, page, pageSize, hasMore }); + res.json({ data, total, page, limit }); }, ); diff --git a/backend/src/integration.test.ts b/backend/src/integration.test.ts index b53a96a..d44e2ab 100644 --- a/backend/src/integration.test.ts +++ b/backend/src/integration.test.ts @@ -348,58 +348,136 @@ describe("Backend Integration Tests", () => { `).run("1", "claimed", now - i, mockStream.sender, 100 + i); } - // Request first page with pageSize 10 + // Request first page with limit 10 const page1Response = await request(app) .get("/api/streams/1/history") - .query({ page: 1, pageSize: 10 }); + .query({ page: 1, limit: 10 }); expect(page1Response.status).toBe(200); expect(page1Response.body.data).toHaveLength(10); expect(page1Response.body.page).toBe(1); - expect(page1Response.body.pageSize).toBe(10); + expect(page1Response.body.limit).toBe(10); expect(page1Response.body.total).toBe(25); - expect(page1Response.body.hasMore).toBe(true); // Request second page const page2Response = await request(app) .get("/api/streams/1/history") - .query({ page: 2, pageSize: 10 }); + .query({ page: 2, limit: 10 }); expect(page2Response.status).toBe(200); expect(page2Response.body.data).toHaveLength(10); expect(page2Response.body.page).toBe(2); - expect(page2Response.body.hasMore).toBe(true); + expect(page2Response.body.limit).toBe(10); // Request third page (last page with 5 items) const page3Response = await request(app) .get("/api/streams/1/history") - .query({ page: 3, pageSize: 10 }); + .query({ page: 3, limit: 10 }); expect(page3Response.status).toBe(200); expect(page3Response.body.data).toHaveLength(5); - expect(page3Response.body.hasMore).toBe(false); + expect(page3Response.body.limit).toBe(10); - // Verify events are ordered by timestamp DESC (newest first) + // Verify events are ordered by timestamp ASC (oldest first) const timestamps = page1Response.body.data.map((e: any) => e.timestamp); for (let i = 1; i < timestamps.length; i++) { - expect(timestamps[i]).toBeLessThanOrEqual(timestamps[i - 1]); + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); } }); - it("should enforce max pageSize of 100", async () => { + it("should return 400 when limit exceeds 100", async () => { const response = await request(app) .get("/api/streams/1/history") - .query({ page: 1, pageSize: 200 }); + .query({ page: 1, limit: 200 }); - expect(response.status).toBe(200); - expect(response.body.pageSize).toBe(100); + expect(response.status).toBe(400); + expect(response.body.code).toBe("VALIDATION_ERROR"); }); - it("should use default pageSize of 20 when not specified", async () => { + it("should use default limit of 50 when not specified", async () => { const response = await request(app).get("/api/streams/1/history"); expect(response.status).toBe(200); - expect(response.body.pageSize).toBe(20); + expect(response.body.limit).toBe(50); + }); + + it("should keep pageSize as a supported alias for limit", async () => { + const response = await request(app) + .get("/api/streams/1/history") + .query({ page: 1, pageSize: 10 }); + + expect(response.status).toBe(200); + expect(response.body.limit).toBe(10); + }); + + it("should correctly paginate stream history with 500+ events", async () => { + const db = getDb(); + const now = Math.floor(Date.now() / 1000); + + // Insert 505 claimed events plus the seeded created event + const insert = db.prepare(` + INSERT INTO stream_events (stream_id, event_type, timestamp, actor, amount) + VALUES (?, ?, ?, ?, ?) + `); + for (let i = 1; i <= 505; i++) { + insert.run("1", "claimed", now - i, mockStream.sender, i); + } + + const totalEvents = 505 + 1; // claimed + created + + // First page: limit 50, descending offset spanning full range + const page1 = await request(app) + .get("/api/streams/1/history") + .query({ page: 1, limit: 100 }); + expect(page1.status).toBe(200); + expect(page1.body.total).toBe(totalEvents); + expect(page1.body.limit).toBe(100); + expect(page1.body.data).toHaveLength(100); + + // Last page should contain the remainder + const lastExpected = Math.ceil(totalEvents / 100); + const lastPage = await request(app) + .get("/api/streams/1/history") + .query({ page: lastExpected, limit: 100 }); + expect(lastPage.status).toBe(200); + expect(lastPage.body.total).toBe(totalEvents); + const remainder = totalEvents - (lastExpected - 1) * 100; + expect(lastPage.body.data).toHaveLength(remainder); + + // Paginated rows must union to exactly the total with no overlap; + // verify ascending order across the first few pages. + const allTimestamps: number[] = []; + for (let p = 1; p <= 3; p++) { + const res = await request(app) + .get("/api/streams/1/history") + .query({ page: p, limit: 100 }); + const ts = res.body.data.map((e: any) => e.timestamp); + for (let i = 1; i < ts.length; i++) { + expect(ts[i]).toBeGreaterThanOrEqual(ts[i - 1]); + } + allTimestamps.push(...ts); + } + // No overlapping rows across pages (each page is a disjoint slice). + expect(new Set(allTimestamps).size).toBe(allTimestamps.length); + }); + + it("should return 400 for invalid page and limit params", async () => { + const invalidQueries = [ + { page: 0, limit: 50 }, + { page: -1, limit: 50 }, + { page: 1, limit: -5 }, + { page: 1, limit: 0 }, + { page: "abc", limit: 50 }, + { page: 1, limit: "x" }, + ]; + + for (const q of invalidQueries) { + const response = await request(app) + .get("/api/streams/1/history") + .query(q); + expect(response.status).toBe(400); + expect(response.body.code).toBe("VALIDATION_ERROR"); + } }); describe("pagination and filter combinations", () => { diff --git a/backend/src/services/streamStore.ts b/backend/src/services/streamStore.ts index 71eb6d0..c696d80 100644 --- a/backend/src/services/streamStore.ts +++ b/backend/src/services/streamStore.ts @@ -809,8 +809,6 @@ export async function createStream(input: StreamInput): Promise { const op = createStreamOperation(contractId, input, startAt); const txToSimulate = new TransactionBuilder(sourceAccount, { - const built = await rpcServer.prepareTransaction( - new TransactionBuilder(sourceAccount, { fee: "1000", networkPassphrase: netPass, }) diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 13928a0..897de53 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1568,7 +1568,7 @@ export const swaggerDocument = { get: { summary: "Get Stream History", description: - "Retrieves the complete event history for a specific stream.", + "Retrieves the complete event history for a specific stream, paginated and sorted ascending by timestamp (oldest first).", parameters: [ { name: "id", @@ -1579,6 +1579,29 @@ export const swaggerDocument = { type: "string", }, }, + { + name: "page", + in: "query", + required: false, + description: "Page number, starting at 1. Defaults to 1.", + schema: { + type: "integer", + minimum: 1, + default: 1, + }, + }, + { + name: "limit", + in: "query", + required: false, + description: "Number of events per page (max 100). Defaults to 50.", + schema: { + type: "integer", + minimum: 1, + maximum: 100, + default: 50, + }, + }, ], responses: { "200": { @@ -1594,11 +1617,33 @@ export const swaggerDocument = { $ref: "#/components/schemas/StreamEvent", }, }, + total: { + type: "integer", + description: "Total number of events for the stream.", + }, + page: { + type: "integer", + description: "Current page number.", + }, + limit: { + type: "integer", + description: "Number of events returned per page.", + }, }, }, }, }, }, + "400": { + description: "Invalid query parameters.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + }, + }, + }, "404": { description: "Stream not found.", content: {