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
3 changes: 2 additions & 1 deletion backend/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
},
);

Expand Down
110 changes: 94 additions & 16 deletions backend/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 0 additions & 2 deletions backend/src/services/streamStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,8 +809,6 @@ export async function createStream(input: StreamInput): Promise<StreamRecord> {
const op = createStreamOperation(contractId, input, startAt);

const txToSimulate = new TransactionBuilder(sourceAccount, {
const built = await rpcServer.prepareTransaction(
new TransactionBuilder(sourceAccount, {
fee: "1000",
networkPassphrase: netPass,
})
Expand Down
47 changes: 46 additions & 1 deletion backend/src/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -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: {
Expand Down
Loading