Skip to content
Merged
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
54 changes: 54 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,57 @@ let app = Router::new()
.route("/", get(handler))
.layer(LoggingLayer);
```

---

## `GET /api/v1/markets/:id/predictions` — cursor-paginated predictions per market

Returns the predictions placed in a specific prediction market (pool), ordered
newest first, with cursor-based pagination.

### Query parameters

| Parameter | Type | Required | Description |
| :-------- | :------ | :------- | :---------------------------------------------------------------- |
| `after` | integer | No | Cursor value from the previous page's `next_cursor` field |
| `limit` | integer | No | Page size, 1–100 (default 20) |

### Example — first page

```bash
curl "http://localhost:3000/api/v1/markets/42/predictions?limit=3"
```

```json
{
"status": "success",
"data": {
"market_id": 42,
"predictions": [
{ "id": 305, "pool_id": 42, "user_address": "GABC...", "outcome": 1, "amount": 500, "created_at": "2026-06-29T08:00:00Z" },
{ "id": 304, "pool_id": 42, "user_address": "GDEF...", "outcome": 0, "amount": 200, "created_at": "2026-06-28T22:30:00Z" },
{ "id": 302, "pool_id": 42, "user_address": "GXYZ...", "outcome": 1, "amount": 100, "created_at": "2026-06-28T10:15:00Z" }
],
"total": 47,
"limit": 3,
"next_cursor": 302
}
}
```

### Example — next page

```bash
curl "http://localhost:3000/api/v1/markets/42/predictions?limit=3&after=302"
```

Pass `next_cursor` from the previous response as `after`. When `next_cursor` is
`null` you have reached the last page.

### Error responses

| Status | Code | When |
| :----- | :--------------------- | :------------------------------- |
| 404 | `NOT_FOUND` | The market ID does not exist |
| 503 | `DATABASE_UNAVAILABLE` | No database pool configured |
| 500 | `INTERNAL_ERROR` | Unexpected database query error |
159 changes: 159 additions & 0 deletions backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,66 @@ pub async fn insert_prediction_from_event(
Ok(())
}

// ── Market Predictions (cursor-based pagination) ──────────────────────────────

/// A single prediction within a market (pool), returned by the market predictions list.
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct MarketPredictionRow {
/// Stable row ID, also used as the pagination cursor.
pub id: i64,
pub pool_id: i64,
pub user_address: String,
pub outcome: i32,
pub amount: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}

/// Fetch a cursor-paginated page of predictions for a given pool.
///
/// `after_id` is the opaque cursor from the previous page (`data.next_cursor`).
/// Pass `None` (or omit the query param) to start from the most recent prediction.
///
/// Results are ordered `id DESC` (newest first), which is stable under
/// concurrent inserts because new rows always get larger IDs.
pub async fn get_market_predictions(
pool: &PgPool,
pool_id: i64,
after_id: Option<i64>,
limit: i64,
) -> Result<Vec<MarketPredictionRow>, sqlx::Error> {
// Requesting one extra row lets us detect whether a next page exists
// without a separate COUNT query on the hot path.
let fetch_limit = limit + 1;

let sql = r#"
SELECT id, pool_id, user_address, outcome, amount, created_at
FROM predictions
WHERE pool_id = $1
AND ($2::bigint IS NULL OR id < $2)
ORDER BY id DESC
LIMIT $3
"#;

sqlx::query_as::<_, MarketPredictionRow>(sql)
.bind(pool_id)
.bind(after_id)
.bind(fetch_limit)
.fetch_all(pool)
.await
}

/// Count total predictions for a given pool (used for the `total` field).
pub async fn count_market_predictions(
pool: &PgPool,
pool_id: i64,
) -> Result<i64, sqlx::Error> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM predictions WHERE pool_id = $1")
.bind(pool_id)
.fetch_one(pool)
.await?;
Ok(count)
}

/// A single row in the referral earnings breakdown — one entry per pool.
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct ReferralEarningRow {
Expand Down Expand Up @@ -1311,3 +1371,102 @@ mod predictions_index_tests {
);
}
}

#[cfg(test)]
mod market_predictions_tests {
use super::*;
use chrono::Utc;

/// Helper: build a page of `MarketPredictionRow` with sequential IDs,
/// descending (newest-first) to match the query order.
fn make_rows(ids: &[i64]) -> Vec<MarketPredictionRow> {
ids.iter()
.map(|&id| MarketPredictionRow {
id,
pool_id: 1,
user_address: format!("G{id:055}"),
outcome: 0,
amount: 100,
created_at: Utc::now(),
})
.collect()
}

// ── has_next / next_cursor logic (mirrors the handler) ──────────────────

/// When the DB returns exactly `limit` rows there is no next page.
#[test]
fn no_next_page_when_rows_equal_limit() {
let limit = 3i64;
let mut rows = make_rows(&[10, 9, 8]);
let has_next = rows.len() as i64 > limit;
if has_next {
rows.truncate(limit as usize);
}
let next_cursor: Option<i64> = if has_next { rows.last().map(|r| r.id) } else { None };

assert!(!has_next);
assert_eq!(next_cursor, None);
assert_eq!(rows.len(), 3);
}

/// When the DB returns `limit + 1` rows there IS a next page; the extra
/// row is trimmed and the cursor points to the last remaining row's ID.
#[test]
fn has_next_page_when_rows_exceed_limit() {
let limit = 3i64;
// Query fetches limit+1 = 4 rows
let mut rows = make_rows(&[10, 9, 8, 7]);
let has_next = rows.len() as i64 > limit;
if has_next {
rows.truncate(limit as usize);
}
let next_cursor: Option<i64> = if has_next { rows.last().map(|r| r.id) } else { None };

assert!(has_next);
assert_eq!(next_cursor, Some(8)); // last row after truncation
assert_eq!(rows.len(), 3);
}

/// An empty result set produces no next cursor and returns an empty slice.
#[test]
fn empty_result_produces_no_cursor() {
let limit = 20i64;
let mut rows: Vec<MarketPredictionRow> = vec![];
let has_next = rows.len() as i64 > limit;
if has_next {
rows.truncate(limit as usize);
}
let next_cursor: Option<i64> = if has_next { rows.last().map(|r| r.id) } else { None };

assert!(!has_next);
assert_eq!(next_cursor, None);
assert!(rows.is_empty());
}

/// A single-row result with limit=1 should NOT show has_next.
/// (limit+1 = 2 fetched, only 1 returned → no next page)
#[test]
fn single_row_within_limit_has_no_next() {
let limit = 1i64;
let mut rows = make_rows(&[5]);
let has_next = rows.len() as i64 > limit;
if has_next {
rows.truncate(limit as usize);
}
let next_cursor: Option<i64> = if has_next { rows.last().map(|r| r.id) } else { None };

assert!(!has_next);
assert_eq!(next_cursor, None);
}

/// `clamp(1, 100)` must reject zero and cap at 100.
#[test]
fn limit_clamp_boundaries() {
assert_eq!(0i64.clamp(1, 100), 1);
assert_eq!((-5i64).clamp(1, 100), 1);
assert_eq!(100i64.clamp(1, 100), 100);
assert_eq!(9999i64.clamp(1, 100), 100);
assert_eq!(50i64.clamp(1, 100), 50);
}
}
43 changes: 43 additions & 0 deletions backend/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ pub struct PoolListResponse {
pub sort_by: String,
}

/// OpenAPI schema for a single prediction in the market predictions list.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct MarketPredictionDoc {
/// Stable row ID; also used as the pagination cursor value.
pub id: i64,
pub pool_id: i64,
pub user_address: String,
pub outcome: i32,
pub amount: i64,
pub created_at: String,
}

/// OpenAPI schema for the cursor-paginated market predictions response.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct MarketPredictionsResponse {
pub market_id: i64,
pub predictions: Vec<MarketPredictionDoc>,
/// Total number of predictions for this market (across all pages).
pub total: i64,
pub limit: i64,
/// Opaque cursor: pass as `?after=<value>` to retrieve the next page.
/// `null` when the current page is the last.
pub next_cursor: Option<i64>,
}

/// OpenAPI schema for a single prediction record.
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct PredictionDoc {
Expand Down Expand Up @@ -240,6 +265,7 @@ pub struct IndexerOkResponse {
api_get_leaderboard,
api_get_user_history,
api_get_user_predictions,
api_get_market_predictions,
api_get_referrals,
api_get_user_referral_earnings,
api_ingest_pool_created,
Expand All @@ -250,6 +276,8 @@ pub struct IndexerOkResponse {
OutcomeOddsDoc,
PoolWithOddsDoc,
PoolListResponse,
MarketPredictionDoc,
MarketPredictionsResponse,
PredictionDoc,
PredictionHistoryResponse,
UserPredictionDoc,
Expand Down Expand Up @@ -371,6 +399,21 @@ async fn api_get_leaderboard() {}
)]
async fn api_get_user_history() {}

#[allow(dead_code)]
#[utoipa::path(get, path = "/api/v1/markets/{market_id}/predictions", tag = "predictions",
params(
("market_id" = i64, Path, description = "On-chain pool (market) identifier"),
("after" = Option<i64>, Query, description = "Cursor from previous page's `next_cursor` field"),
("limit" = Option<i64>, Query, description = "Page size (1–100, default 20)"),
),
responses(
(status = 200, description = "Cursor-paginated predictions for the market", body = MarketPredictionsResponse),
(status = 404, description = "Market not found", body = ErrorResponse),
(status = 503, description = "Database not available", body = ErrorResponse),
)
)]
async fn api_get_market_predictions() {}

#[allow(dead_code)]
#[utoipa::path(get, path = "/api/v1/users/{address}/predictions", tag = "predictions",
params(
Expand Down
69 changes: 69 additions & 0 deletions backend/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,73 @@ mod tests {
"router_with_db should nest under /v1, got: {body}"
);
}

// ── Market predictions: no-DB guard ──────────────────────────────────────

/// Without a DB the market predictions endpoint must return a structured
/// DATABASE_UNAVAILABLE error (not a panic or 500 without an envelope).
#[tokio::test]
async fn market_predictions_without_db_returns_database_unavailable() {
let response = build_router_without_db()
.oneshot(get("/v1/markets/1/predictions"))
.await
.expect("request failed");

// The current get_pools handler also returns 200 for DATABASE_UNAVAILABLE,
// so we follow the same convention here.
assert_eq!(response.status(), StatusCode::OK);

let body = body_string(response.into_body()).await;
assert!(
body.contains("database not available") || body.contains("DATABASE_UNAVAILABLE"),
"should surface DATABASE_UNAVAILABLE, got: {body}"
);
}

/// A non-numeric market ID must return 422 (Axum path extraction failure).
#[tokio::test]
async fn market_predictions_bad_market_id_returns_422() {
let response = build_router_without_db()
.oneshot(get("/v1/markets/not-a-number/predictions"))
.await
.expect("request failed");

assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

/// `?limit` values outside the 1-100 range are clamped, not rejected.
/// With no DB the response is DATABASE_UNAVAILABLE – the important thing is
/// that no 500 / panic occurs from an unclamped limit.
#[tokio::test]
async fn market_predictions_large_limit_is_clamped() {
let response = build_router_without_db()
.oneshot(get("/v1/markets/1/predictions?limit=99999"))
.await
.expect("request failed");

// DATABASE_UNAVAILABLE path, but must not be a 500 from bad arithmetic.
assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

/// With a lazy (unreachable) DB the handler must attempt a query and return
/// a DB error response, not the database-unavailable sentinel (which would
/// mean the pool was never wired through).
#[tokio::test]
async fn market_predictions_with_lazy_db_attempts_query() {
let response = build_router_with_lazy_db()
.oneshot(get("/v1/markets/1/predictions"))
.await
.expect("request failed");

let body = body_string(response.into_body()).await;
assert!(
!body.contains("database not available"),
"router_with_db should wire the pool; got: {body}"
);
// Should either be a DB error or a 404 (market not found), never a panic.
assert!(
body.contains("\"error\""),
"should return an error envelope; got: {body}"
);
}
}
Loading
Loading