Skip to main content

autumn_web/
pagination.rs

1//! Standardized pagination primitives.
2//!
3//! See the [pagination guide](https://github.com/autumn-foundation/autumn/blob/trunk/docs/guide/pagination.md)
4//! for the offset-versus-cursor decision, [`ListQuery`] sorting and filtering,
5//! and the cost pitfalls.
6//!
7//! Autumn ships two complementary flavours of pagination:
8//!
9//! 1. **Offset pagination** ([`PageRequest`] / [`Page<T>`]) — classic
10//!    `?page=N&size=M` with metadata (total elements, total pages).
11//!    Best for stable, browse-style UIs.
12//! 2. **Cursor pagination** ([`CursorRequest`] / [`CursorPage<T>`]) —
13//!    keyset/seek pagination with an opaque `next_cursor` token. Best
14//!    for real-time feeds and infinite scroll: O(1) page depth and
15//!    zero duplicates under concurrent inserts.
16//!
17//! # Quick start (offset)
18//!
19//! Paginating a handler takes three lines: run the count query, run the page
20//! query, and wrap the result in a [`Page`].
21//!
22//! ```rust,ignore
23//! use autumn_web::prelude::*;
24//! use autumn_web::pagination::{Page, PageRequest};
25//! use diesel::prelude::*;
26//! use diesel_async::RunQueryDsl;
27//!
28//! #[get("/api/posts")]
29//! async fn list(page: PageRequest, mut db: Db) -> AutumnResult<Json<Page<Post>>> {
30//!     let total: i64 = posts::table.count().get_result(&mut db).await?;
31//!     let items: Vec<Post> = posts::table
32//!         .limit(page.limit()).offset(page.offset())
33//!         .select(Post::as_select())
34//!         .load(&mut db).await?;
35//!     Ok(Json(Page::new(items, total, &page)))
36//! }
37//! ```
38//!
39//! # Quick start (cursor)
40//!
41//! Cursor pagination is keyset pagination: filter by a stable, deterministic
42//! sort key (with a unique tie-breaker like `id`), fetch `limit + 1` rows,
43//! and let [`CursorPage::from_overfetched`] derive the `next_cursor` from
44//! the boundary row.
45//!
46//! ```rust,ignore
47//! use autumn_web::prelude::*;
48//! use autumn_web::pagination::{CursorPage, CursorRequest};
49//! use chrono::{DateTime, Utc};
50//! use diesel::prelude::*;
51//! use diesel_async::RunQueryDsl;
52//! use serde::{Deserialize, Serialize};
53//!
54//! // Sort key — created_at + id is the stable, deterministic tie-breaker.
55//! #[derive(Serialize, Deserialize)]
56//! struct PostCursor { created_at: DateTime<Utc>, id: i64 }
57//!
58//! #[get("/api/feed")]
59//! async fn feed(cur: CursorRequest, mut db: Db) -> AutumnResult<Json<CursorPage<Post>>> {
60//!     let mut q = posts::table.into_boxed();
61//!     if let Some(c) = cur.decode::<PostCursor>() {
62//!         q = q.filter(
63//!             posts::created_at.lt(c.created_at)
64//!                 .or(posts::created_at.eq(c.created_at).and(posts::id.lt(c.id))),
65//!         );
66//!     }
67//!     let items: Vec<Post> = q
68//!         .order((posts::created_at.desc(), posts::id.desc()))
69//!         .limit(cur.fetch_limit())
70//!         .select(Post::as_select())
71//!         .load(&mut db).await?;
72//!     Ok(Json(CursorPage::from_overfetched(items, &cur, |p| {
73//!         PostCursor { created_at: p.created_at, id: p.id }
74//!     })))
75//! }
76//! ```
77//!
78//! # Query contract
79//!
80//! Offset pagination uses two query parameters:
81//!
82//! | Parameter | Meaning | Default | Clamped to |
83//! |-----------|---------|---------|------------|
84//! | `page` | 1-based page index | `1` | `>= 1` |
85//! | `size` | Items per page | [`DEFAULT_PAGE_SIZE`] | <code>1..=[`MAX_PAGE_SIZE`]</code> |
86//!
87//! Cursor pagination uses:
88//!
89//! | Parameter | Meaning | Default | Clamped to |
90//! |-----------|---------|---------|------------|
91//! | `cursor` | Opaque token from a prior `next_cursor` (omit for first page) | `None` | — |
92//! | `size` | Items per page | [`DEFAULT_PAGE_SIZE`] | <code>1..=[`MAX_PAGE_SIZE`]</code> |
93//!
94//! Requests like `?size=0`, `?size=9999`, `?page=0`, or even `?page=abc`
95//! are silently coerced to the valid range rather than rejected — bad
96//! pagination parameters should not 400. Unparseable or tampered cursors
97//! decode to `None` (i.e. fall back to the first page) for the same reason.
98//!
99//! # Signed cursors (optional)
100//!
101//! Plain cursors are *opaque but unsigned* — the same model used by
102//! Stripe, GitHub, and Relay. Forging one is equivalent to seeking to
103//! an arbitrary offset, which clients can already do with `?page=N`,
104//! so for sort-key-only cursors (timestamp + id) signing adds no real
105//! protection.
106//!
107//! However, if a handler ever encodes anything *sensitive to tampering*
108//! into the cursor payload — a tenant id, a user scope, anything the
109//! handler relies on to filter results — switch to the signed API:
110//!
111//! - [`Cursor::encode_signed`] / [`Cursor::decode_signed`]
112//! - [`CursorRequest::decode_signed`]
113//! - [`CursorPage::from_overfetched_signed`]
114//!
115//! All three take a key as `&[u8]`. Tokens are signed with HMAC-SHA256
116//! and verified in constant time; tampered or unsigned tokens decode
117//! to `None`.
118//!
119//! # Response shape
120//!
121//! [`Page<T>`] serializes as:
122//!
123//! ```json
124//! {
125//!   "content": [ ... ],
126//!   "page": 1,
127//!   "size": 20,
128//!   "total_elements": 137,
129//!   "total_pages": 7,
130//!   "has_next": true,
131//!   "has_previous": false
132//! }
133//! ```
134//!
135//! [`CursorPage<T>`] serializes as:
136//!
137//! ```json
138//! {
139//!   "content": [ ... ],
140//!   "size": 20,
141//!   "next_cursor": "eyJpZCI6MTIzfQ",
142//!   "has_next": true
143//! }
144//! ```
145
146use axum::extract::FromRequestParts;
147use axum::http::request::Parts;
148use axum::http::{HeaderValue, header};
149use axum::response::{IntoResponse, Response};
150use serde::de::DeserializeOwned;
151use serde::{Deserialize, Serialize};
152
153/// Default number of items per page when no `size` is provided.
154pub const DEFAULT_PAGE_SIZE: u32 = 20;
155
156/// Hard upper bound on `size` — prevents clients from requesting huge
157/// pages that could OOM the server or overwhelm the database.
158pub const MAX_PAGE_SIZE: u32 = 100;
159
160// ── PageRequest ─────────────────────────────────────────────────────
161
162/// Pagination parameters parsed from the query string.
163///
164/// Use as a handler extractor to receive `?page=N&size=M`. Both fields
165/// are optional; missing values fall back to [`DEFAULT_PAGE_SIZE`] and
166/// page `1`. Out-of-range *and unparseable* values are clamped rather
167/// than rejected: `page < 1` becomes `1`, `size` is clamped to
168/// <code>1..=[`MAX_PAGE_SIZE`]</code>, and inputs like `?page=abc` are
169/// silently ignored. A list endpoint should never 400 because of a
170/// malformed pager.
171///
172/// # Repository `page()` method
173///
174/// Every `#[repository]`-derived struct generates a `page` method that
175/// accepts a `&PageRequest` and returns a [`Page<Model>`]:
176///
177/// ```rust
178/// use autumn_web::pagination::{Page, PageRequest};
179///
180/// // Simulate what `repo.page(&req)` returns: a Page built from items +
181/// // a total row count.  This doctest exercises the public constructors
182/// // and field visibility (catches pub(crate) regressions).
183/// let req = PageRequest::new(2, 10);
184/// let items: Vec<u32> = (11..=20).collect();
185/// let page: Page<u32> = Page::new(items, 37, &req);
186///
187/// assert_eq!(page.page, 2);
188/// assert_eq!(page.size, 10);
189/// assert_eq!(page.total_elements, 37);
190/// assert_eq!(page.total_pages, 4);
191/// assert!(page.has_next);
192/// assert!(page.has_previous);
193/// assert_eq!(page.content.len(), 10);
194/// ```
195///
196/// # Handler example
197///
198/// ```rust,no_run
199/// use autumn_web::prelude::*;
200/// use autumn_web::pagination::PageRequest;
201///
202/// #[get("/api/items")]
203/// async fn list(page: PageRequest) -> String {
204///     format!("page {} (limit {}, offset {})", page.page(), page.limit(), page.offset())
205/// }
206/// ```
207#[derive(Debug, Clone, Copy, Default, Deserialize)]
208pub struct PageRequest {
209    #[serde(default)]
210    page: Option<u32>,
211    #[serde(default)]
212    size: Option<u32>,
213}
214
215impl PageRequest {
216    /// Construct a [`PageRequest`] explicitly. Values are clamped to the
217    /// valid ranges defined by [`DEFAULT_PAGE_SIZE`] / [`MAX_PAGE_SIZE`].
218    #[must_use]
219    pub const fn new(page: u32, size: u32) -> Self {
220        Self {
221            page: Some(page),
222            size: Some(size),
223        }
224    }
225
226    /// Resolved 1-based page number. `0` or missing is coerced to `1`.
227    #[must_use]
228    pub const fn page(&self) -> u32 {
229        match self.page {
230            Some(p) if p >= 1 => p,
231            _ => 1,
232        }
233    }
234
235    /// Resolved page size, clamped to <code>1..=[`MAX_PAGE_SIZE`]</code>.
236    #[must_use]
237    pub const fn size(&self) -> u32 {
238        match self.size {
239            Some(0) | None => DEFAULT_PAGE_SIZE,
240            Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
241            Some(s) => s,
242        }
243    }
244
245    /// `LIMIT` value for a Diesel or raw SQL query (`== size()`).
246    #[must_use]
247    pub const fn limit(&self) -> i64 {
248        self.size() as i64
249    }
250
251    /// `OFFSET` value for a Diesel or raw SQL query.
252    #[must_use]
253    pub const fn offset(&self) -> i64 {
254        ((self.page() - 1) as i64) * (self.size() as i64)
255    }
256}
257
258impl<S> FromRequestParts<S> for PageRequest
259where
260    S: Send + Sync,
261{
262    type Rejection = std::convert::Infallible;
263
264    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
265        // Manual parse rather than `Query::<Self>::from_request_parts` so
266        // that unparseable values (`?page=abc`, `?size=`, duplicate keys,
267        // percent-encoding errors) fall back to defaults instead of
268        // rejecting the whole request with a 400.
269        Ok(parts.uri.query().map_or_else(Self::default, parse_query))
270    }
271}
272
273/// Best-effort parse of a URL-encoded query string into a [`PageRequest`].
274/// Unknown keys, malformed values, and percent-decoding failures are
275/// silently ignored. Later occurrences of `page`/`size` win, matching the
276/// behaviour of `serde_urlencoded`.
277fn parse_query(query: &str) -> PageRequest {
278    let mut req = PageRequest::default();
279    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
280        match key.as_ref() {
281            "page" => {
282                if let Ok(n) = value.parse::<u32>() {
283                    req.page = Some(n);
284                }
285            }
286            "size" => {
287                if let Ok(n) = value.parse::<u32>() {
288                    req.size = Some(n);
289                }
290            }
291            _ => {}
292        }
293    }
294    req
295}
296
297// ── SortDir ─────────────────────────────────────────────────────────
298
299/// Sort direction for an allowlisted list query column.
300///
301/// This is the single canonical sort-direction type for the framework:
302/// [`ListQuery::direction`] returns it, the `data_table` widget renders
303/// header links with it, and the repository `list()` method applies it.
304/// The `data_table` widget re-exports this same type as
305/// `autumn_web::widgets::SortDir`.
306///
307/// `Asc` is the default — an absent or unrecognized `dir=` query parameter
308/// resolves to ascending order (see [`SortDir::from_param`]).
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum SortDir {
311    /// Ascending order (the default).
312    #[default]
313    Asc,
314    /// Descending order.
315    Desc,
316}
317
318impl SortDir {
319    /// Query-parameter value: `"asc"` or `"desc"`.
320    #[must_use]
321    pub const fn param_value(self) -> &'static str {
322        match self {
323            Self::Asc => "asc",
324            Self::Desc => "desc",
325        }
326    }
327
328    /// `aria-sort` attribute value: `"ascending"` or `"descending"`.
329    #[must_use]
330    pub const fn aria_value(self) -> &'static str {
331        match self {
332            Self::Asc => "ascending",
333            Self::Desc => "descending",
334        }
335    }
336
337    /// Returns the opposite direction.
338    #[must_use]
339    pub const fn toggled(self) -> Self {
340        match self {
341            Self::Asc => Self::Desc,
342            Self::Desc => Self::Asc,
343        }
344    }
345
346    /// Parse a `dir=` query value. Only `"desc"` (case-insensitive) selects
347    /// [`SortDir::Desc`]; **every other value — including an empty string, a
348    /// typo, or a missing parameter — falls back to [`SortDir::Asc`]**. A list
349    /// endpoint must never 400 on a malformed direction.
350    #[must_use]
351    pub const fn from_param(value: &str) -> Self {
352        if value.eq_ignore_ascii_case("desc") {
353            Self::Desc
354        } else {
355            Self::Asc
356        }
357    }
358}
359
360// ── ListQuery ───────────────────────────────────────────────────────
361
362/// Sort/filter parameters parsed from the query string, composing with
363/// [`PageRequest`] to drive an allowlisted list query.
364///
365/// `ListQuery` is the request half of the safe sort/filter feature. It parses
366/// three families of query parameters, all optional and all best-effort:
367///
368/// | Parameter | Meaning | Default |
369/// |-----------|---------|---------|
370/// | `sort` | Column key to order by | model's default order (primary key) |
371/// | `dir` | `asc` or `desc` | `asc` (anything unrecognized → `asc`) |
372/// | `filter[<col>]` | Equality filter on column `<col>` | none |
373///
374/// # The allowlist is the security boundary
375///
376/// `ListQuery` itself performs **no** validation of column names — it only
377/// carries the raw request intent. The safety guarantee lives in the
378/// repository `list()` method generated by `#[repository]`: that method
379/// matches the requested `sort`/`filter[..]` keys against the model's own
380/// columns using Diesel's typed DSL. A key that is not a real column hits the
381/// default match arm and is **silently ignored** — it can never be
382/// interpolated into SQL. This is why an attacker-supplied
383/// `?sort=id;DROP TABLE users` is inert: `id;DROP TABLE users` is not a
384/// column, so it falls through to the model's default ordering.
385///
386/// Because the extractor is [`Infallible`](std::convert::Infallible) it never
387/// rejects a request: an empty `sort` falls back to the default order, an
388/// invalid `dir` falls back to `asc`, and unknown parameters are dropped —
389/// mirroring [`PageRequest`]'s forgiving posture.
390///
391/// # Example
392///
393/// ```rust,ignore
394/// use autumn_web::prelude::*;
395/// use autumn_web::pagination::{ListQuery, PageRequest};
396///
397/// // GET /posts?sort=title&dir=desc&filter[published]=true&page=2
398/// #[get("/posts")]
399/// async fn index(
400///     list_query: ListQuery,
401///     page_req: PageRequest,
402///     repo: PgPostRepository,
403/// ) -> AutumnResult<Json<Page<Post>>> {
404///     // `list()` applies only allowlisted columns; unknown keys are ignored.
405///     let page = repo.list(&list_query, &page_req).await?;
406///     Ok(Json(page))
407/// }
408/// ```
409#[derive(Debug, Clone, Default)]
410pub struct ListQuery {
411    sort: Option<String>,
412    dir: SortDir,
413    filters: Vec<(String, String)>,
414}
415
416impl ListQuery {
417    /// Construct a [`ListQuery`] explicitly. Useful in tests and when driving
418    /// `list()` from code rather than a request.
419    #[must_use]
420    pub fn new(sort: Option<&str>, dir: SortDir, filters: &[(&str, &str)]) -> Self {
421        Self {
422            sort: sort.map(str::to_owned),
423            dir,
424            filters: filters
425                .iter()
426                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
427                .collect(),
428        }
429    }
430
431    /// The requested sort column key, if any.
432    ///
433    /// This is the *raw* requested key — it may name a column that is not in
434    /// the model's allowlist, in which case `list()` ignores it and falls back
435    /// to the default order. Returns `None` when no (or an empty) `sort=` was
436    /// provided.
437    #[must_use]
438    pub fn sort(&self) -> Option<&str> {
439        self.sort.as_deref()
440    }
441
442    /// The resolved sort direction, defaulting to [`SortDir::Asc`].
443    #[must_use]
444    pub const fn direction(&self) -> SortDir {
445        self.dir
446    }
447
448    /// Iterate over the requested equality filters as `(column, value)` pairs.
449    ///
450    /// As with [`sort`](Self::sort), these are raw requested keys: `list()`
451    /// applies only the ones that name a real, filterable column and silently
452    /// drops the rest.
453    pub fn filters(&self) -> impl Iterator<Item = (&str, &str)> {
454        self.filters.iter().map(|(k, v)| (k.as_str(), v.as_str()))
455    }
456
457    /// `true` when no sort, direction override, or filters were requested.
458    #[must_use]
459    pub fn is_empty(&self) -> bool {
460        self.sort.is_none() && self.dir == SortDir::Asc && self.filters.is_empty()
461    }
462}
463
464/// No-op fallback for the repository `list()` sort/filter allowlist.
465///
466/// The `#[model]` macro generates **typed, per-column** inherent
467/// `__autumn_list_apply_order` / `__autumn_list_apply_filters` associated
468/// functions on the model struct; those take precedence over the no-op defaults
469/// here. A `#[repository]` declared on a hand-written model (one not produced by
470/// `#[model]`, so with no column metadata) falls back to these no-ops: `list()`
471/// still paginates, but applies no sort/filter — there is no column allowlist to
472/// enforce, so nothing from the request touches the query. This keeps `list()`
473/// available on every repository while guaranteeing the allowlist is the only
474/// path a column name can reach SQL.
475#[doc(hidden)]
476pub trait AutumnListable {
477    #[doc(hidden)]
478    fn __autumn_list_apply_order<Q>(query_builder: Q, _list: &ListQuery) -> Q {
479        query_builder
480    }
481    #[doc(hidden)]
482    fn __autumn_list_apply_filters<Q>(query_builder: Q, _list: &ListQuery) -> Q {
483        query_builder
484    }
485}
486
487impl<T> AutumnListable for T {}
488
489impl<S> FromRequestParts<S> for ListQuery
490where
491    S: Send + Sync,
492{
493    type Rejection = std::convert::Infallible;
494
495    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
496        // Best-effort parse, mirroring `PageRequest`: a list endpoint must
497        // never 400 on a malformed sort/filter parameter.
498        Ok(parts
499            .uri
500            .query()
501            .map_or_else(Self::default, parse_list_query))
502    }
503}
504
505/// Best-effort parse of a URL-encoded query string into a [`ListQuery`].
506///
507/// - `sort=<key>` — the last non-empty occurrence wins.
508/// - `dir=<asc|desc>` — anything other than `desc` (case-insensitive) → `asc`.
509/// - `filter[<col>]=<val>` — every occurrence is collected; an empty `<col>`
510///   is dropped. Duplicate columns are all kept (the allowlist layer applies
511///   each in turn; the last equality filter for a column effectively wins at
512///   the SQL level).
513///
514/// Unknown keys (including `page`/`size`, which belong to [`PageRequest`]) are
515/// ignored so the two extractors compose without interference.
516fn parse_list_query(query: &str) -> ListQuery {
517    let mut req = ListQuery::default();
518    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
519        let key = key.as_ref();
520        if key == "sort" {
521            if value.is_empty() {
522                req.sort = None;
523            } else {
524                req.sort = Some(value.into_owned());
525            }
526        } else if key == "dir" {
527            req.dir = SortDir::from_param(&value);
528        } else if let Some(col) = key
529            .strip_prefix("filter[")
530            .and_then(|rest| rest.strip_suffix(']'))
531            && !col.is_empty()
532        {
533            req.filters.push((col.to_owned(), value.into_owned()));
534        }
535    }
536    req
537}
538
539// ── Page<T> ─────────────────────────────────────────────────────────
540
541/// Paginated response wrapper with navigation metadata.
542///
543/// `Page` serializes to JSON for API responses and exposes the fields a
544/// Maud template needs to render pager links (previous/next, page index,
545/// total pages).
546///
547/// Construct one with [`Page::new`] after running your count + page
548/// queries, or with [`Page::empty`] when you have no data to return.
549///
550/// # JSON shape
551///
552/// ```json
553/// {
554///   "content": [ /* T items */ ],
555///   "page": 1,
556///   "size": 20,
557///   "total_elements": 137,
558///   "total_pages": 7,
559///   "has_next": true,
560///   "has_previous": false
561/// }
562/// ```
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct Page<T> {
565    /// The items on this page.
566    pub content: Vec<T>,
567    /// Current 1-based page index.
568    pub page: u32,
569    /// Page size used to produce `content`.
570    pub size: u32,
571    /// Total number of items across every page.
572    pub total_elements: u64,
573    /// Total number of pages (`ceil(total_elements / size)`), minimum `1`.
574    pub total_pages: u32,
575    /// Whether there is a page after this one.
576    pub has_next: bool,
577    /// Whether there is a page before this one.
578    pub has_previous: bool,
579}
580
581impl<T> Page<T> {
582    /// Build a page from the materialized `items` and the total row
583    /// count returned by the database.
584    ///
585    /// `total` is accepted as `i64` to match Diesel's
586    /// `COUNT(*)` result type; values below zero are treated as zero.
587    #[must_use]
588    pub fn new(items: Vec<T>, total: i64, request: &PageRequest) -> Self {
589        let size = request.size();
590        let page = request.page();
591        let total_elements = u64::try_from(total).unwrap_or(0);
592
593        // ceil(total / size), minimum 1 so an empty result still
594        // reports `total_pages = 1` — callers don't have to branch on
595        // "no rows" when rendering a pager.
596        let total_pages = if total_elements == 0 {
597            1
598        } else {
599            // size() is always >= 1, so this division is safe.
600            u32::try_from(total_elements.div_ceil(u64::from(size))).unwrap_or(u32::MAX)
601        };
602
603        Self {
604            content: items,
605            page,
606            size,
607            total_elements,
608            total_pages,
609            has_next: page < total_pages,
610            has_previous: page > 1,
611        }
612    }
613
614    /// Build an empty page using the caller's request parameters.
615    ///
616    /// Useful when a filter short-circuits before hitting the database.
617    #[must_use]
618    pub fn empty(request: &PageRequest) -> Self {
619        Self::new(Vec::new(), 0, request)
620    }
621
622    /// Build a metadata-only page from raw pagination counters, without a
623    /// `PageRequest`. Useful for bridging external count types (e.g. `u64`)
624    /// into the standard `Page` shape for rendering or serialisation.
625    ///
626    /// `page` is clamped to `[1, u32::MAX]`; `total_pages` is clamped to
627    /// at least `1` so callers don't have to special-case empty result sets.
628    /// `content` is empty — use [`Page::new`] when you have items.
629    #[must_use]
630    pub fn from_raw(page: u32, size: u32, total_elements: u64, total_pages: u32) -> Self {
631        let page = page.max(1);
632        let total_pages = total_pages.max(1);
633        Self {
634            content: Vec::new(),
635            page,
636            size,
637            total_elements,
638            total_pages,
639            has_next: page < total_pages,
640            has_previous: page > 1,
641        }
642    }
643
644    /// Paginate an already fully-materialized collection in memory.
645    ///
646    /// Used when every row must be loaded before the page window can be
647    /// applied — for example a list endpoint that filters each row through a
648    /// per-row authorization policy (the filter has to run in Rust, so a
649    /// SQL `LIMIT/OFFSET` can't bound the result on its own). The returned
650    /// [`Page`] carries the full `total_elements` (so `total_pages` and the
651    /// nav links are correct) but only the `request`-selected window in
652    /// `content`.
653    ///
654    /// Prefer [`Page::new`] with a SQL `LIMIT/OFFSET` + `COUNT(*)` whenever
655    /// the window can be pushed into the database.
656    #[must_use]
657    pub fn paginate_in_memory(all: Vec<T>, request: &PageRequest) -> Self {
658        let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
659        let offset = usize::try_from(request.offset()).unwrap_or(usize::MAX);
660        let size = request.size() as usize;
661        let content: Vec<T> = all.into_iter().skip(offset).take(size).collect();
662        Self::new(content, total, request)
663    }
664
665    /// Transform the content while preserving pagination metadata.
666    ///
667    /// Typical use: converting database rows into DTOs for JSON output
668    /// without re-running the count query.
669    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
670        Page {
671            content: self.content.into_iter().map(f).collect(),
672            page: self.page,
673            size: self.size,
674            total_elements: self.total_elements,
675            total_pages: self.total_pages,
676            has_next: self.has_next,
677            has_previous: self.has_previous,
678        }
679    }
680}
681
682/// Build an RFC 8288 `Link` header value for an offset [`Page`].
683///
684/// Uses *relative* query-only references (`<?page=2&size=20>`) which a client
685/// resolves against the request URL — so the response doesn't need to know its
686/// own mount path. `first` and `last` are always emitted (offset pagination
687/// always knows both bounds); `prev`/`next` only when they exist.
688fn page_link_header_value(page: u32, size: u32, total_pages: u32, has_next: bool) -> String {
689    let mut links: Vec<String> = Vec::with_capacity(4);
690    let rel = |target: u32, name: &str| format!("<?page={target}&size={size}>; rel=\"{name}\"");
691    links.push(rel(1, "first"));
692    if page > 1 {
693        links.push(rel(page - 1, "prev"));
694    }
695    if has_next {
696        links.push(rel(page + 1, "next"));
697    }
698    // For an empty collection `total_pages` is 0; clamp to 1 so the `last`
699    // link stays a valid 1-indexed page rather than `page=0`.
700    let last_page = total_pages.max(1);
701    links.push(rel(last_page, "last"));
702    links.join(", ")
703}
704
705impl<T> IntoResponse for Page<T>
706where
707    T: Serialize,
708{
709    fn into_response(self) -> Response {
710        // Snapshot the nav counters before `self` is moved into `Json`.
711        let (page, size, total_pages, has_next) =
712            (self.page, self.size, self.total_pages, self.has_next);
713        let mut response = axum::Json(self).into_response();
714        if let Ok(value) =
715            HeaderValue::from_str(&page_link_header_value(page, size, total_pages, has_next))
716        {
717            response.headers_mut().insert(header::LINK, value);
718        }
719        response
720    }
721}
722
723impl<T> IntoResponse for CursorPage<T>
724where
725    T: Serialize,
726{
727    fn into_response(self) -> Response {
728        // Only a `next` link is meaningful for keyset pagination — there is no
729        // cheap way back to `first`/`prev`/`last` without a full scan.
730        let next_link = self
731            .next_cursor
732            .as_ref()
733            .filter(|_| self.has_next)
734            .map(|token| {
735                let encoded: String =
736                    url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
737                format!("<?cursor={encoded}&size={}>; rel=\"next\"", self.size)
738            });
739        let mut response = axum::Json(self).into_response();
740        if let Some(link) = next_link
741            && let Ok(value) = HeaderValue::from_str(&link)
742        {
743            response.headers_mut().insert(header::LINK, value);
744        }
745        response
746    }
747}
748
749// ── Cursor encoding ─────────────────────────────────────────────────
750//
751// Cursor tokens are base64url-encoded JSON. base64url is used (rather
752// than the standard alphabet) so that tokens are safe to embed in a
753// URL without percent-encoding, and padding (`=`) is omitted to keep
754// them tidy. Tokens are *opaque* to clients — encoding the structure
755// keeps callers from forging cursors but, more importantly, lets the
756// server change the schema without breaking the wire contract.
757
758const BASE64URL_ALPHABET: &[u8; 64] =
759    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
760
761fn base64url_encode(input: &[u8]) -> String {
762    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
763    let (chunks, rem) = input.as_chunks::<3>();
764    for chunk in chunks {
765        let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
766        out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
767        out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
768        out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
769        out.push(BASE64URL_ALPHABET[(n & 0x3F) as usize] as char);
770    }
771    match rem.len() {
772        0 => {}
773        1 => {
774            let n = u32::from(rem[0]) << 16;
775            out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
776            out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
777        }
778        2 => {
779            let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
780            out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
781            out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
782            out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
783        }
784        _ => unreachable!("as_chunks remainder is < 3 by construction"),
785    }
786    out
787}
788
789fn base64url_decode(input: &str) -> Option<Vec<u8>> {
790    let bytes = input.as_bytes();
791    let value = |b: u8| -> Option<u32> {
792        match b {
793            b'A'..=b'Z' => Some(u32::from(b - b'A')),
794            b'a'..=b'z' => Some(u32::from(b - b'a') + 26),
795            b'0'..=b'9' => Some(u32::from(b - b'0') + 52),
796            b'-' => Some(62),
797            b'_' => Some(63),
798            _ => None,
799        }
800    };
801    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
802    let mut i = 0;
803    while i + 4 <= bytes.len() {
804        let n = (value(bytes[i])? << 18)
805            | (value(bytes[i + 1])? << 12)
806            | (value(bytes[i + 2])? << 6)
807            | value(bytes[i + 3])?;
808        out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
809        out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
810        out.push(u8::try_from(n & 0xFF).ok()?);
811        i += 4;
812    }
813    match bytes.len() - i {
814        0 => {}
815        1 => return None, // a single trailing char is not a valid base64 group
816        2 => {
817            let n = (value(bytes[i])? << 18) | (value(bytes[i + 1])? << 12);
818            out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
819        }
820        3 => {
821            let n = (value(bytes[i])? << 18)
822                | (value(bytes[i + 1])? << 12)
823                | (value(bytes[i + 2])? << 6);
824            out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
825            out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
826        }
827        _ => unreachable!("bytes.len() - i is < 4 by the loop condition"),
828    }
829    Some(out)
830}
831
832/// Opaque cursor tokens for cursor-based pagination.
833///
834/// A cursor wraps a serializable sort-key payload (typically a struct
835/// containing a timestamp and a unique tie-breaker like `id`) into a
836/// URL-safe string. The on-the-wire format is base64url-encoded JSON;
837/// callers should treat tokens as opaque.
838///
839/// # Examples
840///
841/// ```rust
842/// use autumn_web::pagination::Cursor;
843/// use serde::{Deserialize, Serialize};
844///
845/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
846/// struct Key { id: i64 }
847///
848/// let token = Cursor::encode(&Key { id: 42 }).unwrap();
849/// let decoded: Key = Cursor::decode(&token).unwrap();
850/// assert_eq!(decoded, Key { id: 42 });
851/// ```
852pub struct Cursor;
853
854impl Cursor {
855    /// Encode a serializable value as an opaque URL-safe cursor token.
856    ///
857    /// # Errors
858    ///
859    /// Returns `serde_json::Error` if the value cannot be serialized.
860    pub fn encode<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
861        Ok(base64url_encode(&serde_json::to_vec(value)?))
862    }
863
864    /// Decode an opaque cursor token back into a typed value.
865    ///
866    /// Returns `None` for any malformed token — invalid base64, invalid
867    /// UTF-8, or JSON that doesn't match `T`. A list endpoint should
868    /// silently fall back to the first page rather than 400 on a
869    /// tampered or stale cursor, matching the forgiving behaviour of
870    /// the `?page=` / `?size=` parser.
871    #[must_use]
872    pub fn decode<T: DeserializeOwned>(token: &str) -> Option<T> {
873        let bytes = base64url_decode(token)?;
874        serde_json::from_slice(&bytes).ok()
875    }
876
877    /// Encode a value as a *signed* cursor token using HMAC-SHA256.
878    ///
879    /// Use this when the cursor payload encodes anything sensitive to
880    /// tampering — for example a tenant boundary, a user id, or any
881    /// scope a handler relies on to filter results. Without signing,
882    /// a client could edit the JSON and re-encode it. Plain
883    /// [`Cursor::encode`] is fine for cursors that only carry sort-key
884    /// values (timestamps, primary keys), since forging one is
885    /// equivalent to seeking to an arbitrary offset.
886    ///
887    /// The token format is `<base64url(json)>.<base64url(hmac)>`.
888    /// The signature covers exactly the payload bytes; the encoded
889    /// payload itself is not encrypted (cursors are not secrets).
890    ///
891    /// # Errors
892    ///
893    /// Returns `serde_json::Error` if the value cannot be serialized.
894    pub fn encode_signed<T: Serialize>(value: &T, key: &[u8]) -> Result<String, serde_json::Error> {
895        let payload = serde_json::to_vec(value)?;
896        let payload_b64 = base64url_encode(&payload);
897        let mac = hmac_sha256(key, payload_b64.as_bytes());
898        let sig_b64 = base64url_encode(&mac);
899        Ok(format!("{payload_b64}.{sig_b64}"))
900    }
901
902    /// Decode a signed cursor token, verifying its HMAC-SHA256 signature.
903    ///
904    /// Returns `None` for any of: malformed structure, malformed
905    /// base64, signature mismatch, JSON that doesn't match `T`. The
906    /// signature is verified in constant time. A handler that uses
907    /// this should treat `None` the same way it treats no cursor
908    /// (fall back to first page) rather than returning an error —
909    /// the goal is to ignore tampered cursors, not to surface them
910    /// as user-facing failures.
911    #[must_use]
912    pub fn decode_signed<T: DeserializeOwned>(token: &str, key: &[u8]) -> Option<T> {
913        let (payload_b64, sig_b64) = token.split_once('.')?;
914        let expected_sig = base64url_decode(sig_b64)?;
915        let actual_sig = hmac_sha256(key, payload_b64.as_bytes());
916        if !constant_time_eq(&expected_sig, &actual_sig) {
917            return None;
918        }
919        let payload = base64url_decode(payload_b64)?;
920        serde_json::from_slice(&payload).ok()
921    }
922}
923
924fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
925    use hmac::{Hmac, KeyInit, Mac};
926    use sha2::Sha256;
927    // `Hmac::new_from_slice` accepts any key length.
928    let mut mac =
929        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
930    mac.update(message);
931    mac.finalize().into_bytes().into()
932}
933
934fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
935    use subtle::ConstantTimeEq;
936    a.ct_eq(b).into()
937}
938
939// ── CursorRequest ───────────────────────────────────────────────────
940
941/// Cursor pagination parameters parsed from the query string.
942///
943/// Use as a handler extractor to receive `?cursor=<token>&size=<n>`.
944/// Both fields are optional: missing `cursor` means "give me the
945/// first page", missing `size` means [`DEFAULT_PAGE_SIZE`]. The same
946/// forgiving coercion as [`PageRequest`] applies — `?size=0` falls
947/// back to the default, `?size=9999` is clamped to [`MAX_PAGE_SIZE`],
948/// and unparseable values are silently ignored. A malformed cursor is
949/// treated as no cursor.
950///
951/// # Examples
952///
953/// ```rust,no_run
954/// use autumn_web::prelude::*;
955/// use autumn_web::pagination::CursorRequest;
956///
957/// #[get("/api/feed")]
958/// async fn feed(cur: CursorRequest) -> String {
959///     format!("size={}, cursor={:?}", cur.size(), cur.cursor())
960/// }
961/// ```
962#[derive(Debug, Clone, Default, Deserialize)]
963pub struct CursorRequest {
964    #[serde(default)]
965    cursor: Option<String>,
966    #[serde(default)]
967    size: Option<u32>,
968}
969
970impl CursorRequest {
971    /// Construct a [`CursorRequest`] explicitly. Useful in tests.
972    #[must_use]
973    pub const fn new(cursor: Option<String>, size: u32) -> Self {
974        Self {
975            cursor,
976            size: Some(size),
977        }
978    }
979
980    /// The raw, opaque cursor token, if any.
981    #[must_use]
982    pub fn cursor(&self) -> Option<&str> {
983        self.cursor.as_deref()
984    }
985
986    /// Decode the cursor into a typed sort-key value.
987    ///
988    /// Returns `None` if the cursor is missing or unparseable. Use
989    /// this in handlers to add the keyset filter to the query.
990    #[must_use]
991    pub fn decode<T: DeserializeOwned>(&self) -> Option<T> {
992        Cursor::decode(self.cursor.as_deref()?)
993    }
994
995    /// Decode a *signed* cursor, verifying its HMAC-SHA256 signature
996    /// against `key`. See [`Cursor::decode_signed`] for the threat
997    /// model and when to use signed vs. unsigned cursors.
998    ///
999    /// Returns `None` if the cursor is missing, malformed, or has an
1000    /// invalid signature.
1001    #[must_use]
1002    pub fn decode_signed<T: DeserializeOwned>(&self, key: &[u8]) -> Option<T> {
1003        Cursor::decode_signed(self.cursor.as_deref()?, key)
1004    }
1005
1006    /// Resolved page size, clamped to <code>1..=[`MAX_PAGE_SIZE`]</code>.
1007    #[must_use]
1008    pub const fn size(&self) -> u32 {
1009        match self.size {
1010            Some(0) | None => DEFAULT_PAGE_SIZE,
1011            Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
1012            Some(s) => s,
1013        }
1014    }
1015
1016    /// `LIMIT` value matching the requested page size.
1017    #[must_use]
1018    pub const fn limit(&self) -> i64 {
1019        self.size() as i64
1020    }
1021
1022    /// `LIMIT + 1` — fetch one extra row so the handler can detect
1023    /// whether a next page exists without an extra query.
1024    ///
1025    /// This is the value to pass to Diesel's `.limit(...)` when using
1026    /// [`CursorPage::from_overfetched`].
1027    #[must_use]
1028    pub const fn fetch_limit(&self) -> i64 {
1029        self.size() as i64 + 1
1030    }
1031}
1032
1033impl<S> FromRequestParts<S> for CursorRequest
1034where
1035    S: Send + Sync,
1036{
1037    type Rejection = std::convert::Infallible;
1038
1039    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
1040        Ok(parts
1041            .uri
1042            .query()
1043            .map_or_else(Self::default, parse_cursor_query))
1044    }
1045}
1046
1047/// Best-effort parse of a URL-encoded query string into a [`CursorRequest`].
1048/// Same coercion rules as [`parse_query`]: unknown keys, malformed values,
1049/// and percent-decoding failures are silently ignored. Later occurrences of
1050/// `cursor`/`size` win.
1051fn parse_cursor_query(query: &str) -> CursorRequest {
1052    let mut req = CursorRequest::default();
1053    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1054        match key.as_ref() {
1055            "cursor" if !value.is_empty() => {
1056                req.cursor = Some(value.into_owned());
1057            }
1058            "size" => {
1059                if let Ok(n) = value.parse::<u32>() {
1060                    req.size = Some(n);
1061                }
1062            }
1063            _ => {}
1064        }
1065    }
1066    req
1067}
1068
1069// ── CursorPage<T> ───────────────────────────────────────────────────
1070
1071/// Paginated response wrapper for cursor-based pagination.
1072///
1073/// `CursorPage` serializes to JSON for API responses and is the
1074/// counterpart to [`Page<T>`] for keyset/seek pagination. Construct
1075/// one with [`CursorPage::from_overfetched`] after running a single
1076/// `LIMIT n+1` query.
1077///
1078/// # JSON shape
1079///
1080/// ```json
1081/// {
1082///   "content": [ /* T items */ ],
1083///   "size": 20,
1084///   "next_cursor": "eyJpZCI6MTIzfQ",
1085///   "has_next": true
1086/// }
1087/// ```
1088///
1089/// `next_cursor` is `null` on the last page (`has_next == false`).
1090#[derive(Debug, Clone, Serialize, Deserialize)]
1091pub struct CursorPage<T> {
1092    /// The items on this page.
1093    pub content: Vec<T>,
1094    /// Page size used to produce `content`.
1095    pub size: u32,
1096    /// Opaque cursor for the next page, or `None` if this is the last page.
1097    pub next_cursor: Option<String>,
1098    /// Whether there is a page after this one.
1099    pub has_next: bool,
1100}
1101
1102impl<T> CursorPage<T> {
1103    /// Build a page from an over-fetched result set.
1104    ///
1105    /// The caller fetches `request.fetch_limit()` rows (one more than
1106    /// the page size). If that returned the extra row, this method
1107    /// truncates `content` back to the page size, marks `has_next`
1108    /// true, and derives `next_cursor` from the *last kept* item via
1109    /// `cursor_fn`. If fewer rows came back, this is the last page
1110    /// and `next_cursor` is `None`.
1111    ///
1112    /// `cursor_fn` is called at most once.
1113    ///
1114    /// # Errors / panics
1115    ///
1116    /// If `cursor_fn`'s value fails to serialize (extremely unlikely
1117    /// for the simple sort-key structs typically used here),
1118    /// `next_cursor` is set to `None` and `has_next` to `false`. The
1119    /// page is still returned — the alternative would be a 500 on a
1120    /// successful query, which is the wrong tradeoff for a list
1121    /// endpoint.
1122    #[must_use]
1123    pub fn from_overfetched<K, F>(items: Vec<T>, request: &CursorRequest, cursor_fn: F) -> Self
1124    where
1125        K: Serialize,
1126        F: FnOnce(&T) -> K,
1127    {
1128        Self::from_overfetched_inner(items, request, cursor_fn, |k| Cursor::encode(&k).ok())
1129    }
1130
1131    /// Variant of [`Self::from_overfetched`] that signs `next_cursor`
1132    /// with HMAC-SHA256 using `key`.
1133    ///
1134    /// Use this when the cursor payload encodes anything sensitive to
1135    /// tampering (tenant ids, user scopes). For sort-key-only cursors
1136    /// (timestamp + id), plain [`Self::from_overfetched`] is fine —
1137    /// forging an unsigned cursor is equivalent to seeking to an
1138    /// arbitrary offset, which clients can already do with `?page=N`.
1139    ///
1140    /// The corresponding extractor side calls
1141    /// [`CursorRequest::decode_signed`] with the same key.
1142    #[must_use]
1143    pub fn from_overfetched_signed<K, F>(
1144        items: Vec<T>,
1145        request: &CursorRequest,
1146        key: &[u8],
1147        cursor_fn: F,
1148    ) -> Self
1149    where
1150        K: Serialize,
1151        F: FnOnce(&T) -> K,
1152    {
1153        Self::from_overfetched_inner(items, request, cursor_fn, |k| {
1154            Cursor::encode_signed(&k, key).ok()
1155        })
1156    }
1157
1158    fn from_overfetched_inner<K, F, E>(
1159        mut items: Vec<T>,
1160        request: &CursorRequest,
1161        cursor_fn: F,
1162        encode: E,
1163    ) -> Self
1164    where
1165        K: Serialize,
1166        F: FnOnce(&T) -> K,
1167        E: FnOnce(K) -> Option<String>,
1168    {
1169        let size = request.size();
1170        let limit = size as usize;
1171        let has_next = items.len() > limit;
1172        if has_next {
1173            items.truncate(limit);
1174        }
1175        let next_cursor = if has_next {
1176            // The boundary row is the last one we kept. Encoding from
1177            // it (rather than the popped row) means the next query
1178            // can use a strict inequality and still see every row,
1179            // even under concurrent inserts that land between pages.
1180            items.last().map(cursor_fn).and_then(encode)
1181        } else {
1182            None
1183        };
1184        // If encoding failed for some reason, don't claim a next page
1185        // we can't actually serve.
1186        let has_next = has_next && next_cursor.is_some();
1187        Self {
1188            content: items,
1189            size,
1190            next_cursor,
1191            has_next,
1192        }
1193    }
1194
1195    /// Build an empty page using the caller's request parameters.
1196    ///
1197    /// Useful when a filter short-circuits before hitting the database.
1198    #[must_use]
1199    pub const fn empty(request: &CursorRequest) -> Self {
1200        Self {
1201            content: Vec::new(),
1202            size: request.size(),
1203            next_cursor: None,
1204            has_next: false,
1205        }
1206    }
1207
1208    /// Transform the content while preserving pagination metadata.
1209    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPage<U> {
1210        CursorPage {
1211            content: self.content.into_iter().map(f).collect(),
1212            size: self.size,
1213            next_cursor: self.next_cursor,
1214            has_next: self.has_next,
1215        }
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use axum::Router;
1223    use axum::body::Body;
1224    use axum::http::{Request, StatusCode};
1225    use axum::routing::get;
1226    use tower::ServiceExt;
1227
1228    // ── PageRequest coercion ────────────────────────────────────
1229
1230    #[test]
1231    fn defaults_when_nothing_provided() {
1232        let r = PageRequest::default();
1233        assert_eq!(r.page(), 1);
1234        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1235        assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1236        assert_eq!(r.offset(), 0);
1237    }
1238
1239    #[test]
1240    fn page_zero_is_coerced_to_one() {
1241        let r = PageRequest::new(0, 10);
1242        assert_eq!(r.page(), 1);
1243        assert_eq!(r.offset(), 0);
1244    }
1245
1246    #[test]
1247    fn size_is_clamped_to_max() {
1248        let r = PageRequest::new(1, 9_999);
1249        assert_eq!(r.size(), MAX_PAGE_SIZE);
1250        assert_eq!(r.limit(), i64::from(MAX_PAGE_SIZE));
1251
1252        let exact = PageRequest::new(1, MAX_PAGE_SIZE);
1253        assert_eq!(exact.size(), MAX_PAGE_SIZE);
1254
1255        let over = PageRequest::new(1, MAX_PAGE_SIZE + 1);
1256        assert_eq!(over.size(), MAX_PAGE_SIZE);
1257
1258        let under = PageRequest::new(1, MAX_PAGE_SIZE - 1);
1259        assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1260    }
1261
1262    #[test]
1263    fn size_zero_falls_back_to_default() {
1264        let r = PageRequest::new(3, 0);
1265        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1266    }
1267
1268    #[test]
1269    fn offset_matches_page_and_size() {
1270        let r = PageRequest::new(3, 25);
1271        assert_eq!(r.offset(), 50);
1272        assert_eq!(r.limit(), 25);
1273    }
1274
1275    // ── Page metadata ──────────────────────────────────────────
1276
1277    #[test]
1278    fn empty_page_has_one_total_page() {
1279        let req = PageRequest::new(5, 50);
1280        let page: Page<i32> = Page::empty(&req);
1281        assert_eq!(page.page, 5);
1282        assert_eq!(page.size, 50);
1283        assert_eq!(page.total_elements, 0);
1284        assert_eq!(page.total_pages, 1);
1285        assert!(!page.has_next);
1286        assert!(page.has_previous);
1287        assert!(page.content.is_empty());
1288    }
1289
1290    #[test]
1291    fn metadata_reflects_middle_page() {
1292        let req = PageRequest::new(3, 20);
1293        let page = Page::new(vec![1_i32; 20], 137, &req);
1294        assert_eq!(page.page, 3);
1295        assert_eq!(page.size, 20);
1296        assert_eq!(page.total_elements, 137);
1297        assert_eq!(page.total_pages, 7); // ceil(137/20) == 7
1298        assert!(page.has_next);
1299        assert!(page.has_previous);
1300    }
1301
1302    #[test]
1303    fn metadata_reflects_last_page() {
1304        let req = PageRequest::new(7, 20);
1305        let page = Page::new(vec![1_i32; 17], 137, &req);
1306        assert_eq!(page.total_pages, 7);
1307        assert!(!page.has_next);
1308        assert!(page.has_previous);
1309    }
1310
1311    #[test]
1312    fn negative_total_is_treated_as_zero() {
1313        let page: Page<i32> = Page::new(vec![], -1, &PageRequest::default());
1314        assert_eq!(page.total_elements, 0);
1315        assert_eq!(page.total_pages, 1);
1316    }
1317
1318    #[test]
1319    fn map_preserves_metadata() {
1320        let req = PageRequest::new(2, 10);
1321        let page = Page::new(vec![1_i32, 2, 3], 25, &req);
1322        let mapped = page.map(|n| n.to_string());
1323        assert_eq!(mapped.page, 2);
1324        assert_eq!(mapped.size, 10);
1325        assert_eq!(mapped.total_elements, 25);
1326        assert_eq!(mapped.total_pages, 3);
1327        assert!(mapped.has_next);
1328        assert!(mapped.has_previous);
1329        assert_eq!(mapped.content, vec!["1", "2", "3"]);
1330    }
1331
1332    // ── JSON serialization ─────────────────────────────────────
1333
1334    #[test]
1335    fn page_serializes_to_expected_shape() {
1336        let req = PageRequest::new(2, 10);
1337        let page = Page::new(vec!["a", "b"], 25, &req);
1338        let json = serde_json::to_value(&page).unwrap();
1339        assert_eq!(json["page"], 2);
1340        assert_eq!(json["size"], 10);
1341        assert_eq!(json["total_elements"], 25);
1342        assert_eq!(json["total_pages"], 3);
1343        assert_eq!(json["has_next"], true);
1344        assert_eq!(json["has_previous"], true);
1345        assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1346    }
1347
1348    // ── Extractor tests ────────────────────────────────────────
1349
1350    async fn echo(page: PageRequest) -> String {
1351        format!("{}:{}:{}", page.page(), page.size(), page.offset())
1352    }
1353
1354    async fn fetch(uri: &str) -> (StatusCode, String) {
1355        let app = Router::new().route("/items", get(echo));
1356        let res = app
1357            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1358            .await
1359            .unwrap();
1360        let status = res.status();
1361        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1362            .await
1363            .unwrap();
1364        (status, String::from_utf8(bytes.to_vec()).unwrap())
1365    }
1366
1367    #[tokio::test]
1368    async fn extractor_uses_defaults_when_query_missing() {
1369        let (status, body) = fetch("/items").await;
1370        assert_eq!(status, StatusCode::OK);
1371        assert_eq!(body, format!("1:{DEFAULT_PAGE_SIZE}:0"));
1372    }
1373
1374    #[tokio::test]
1375    async fn extractor_parses_page_and_size() {
1376        let (status, body) = fetch("/items?page=4&size=25").await;
1377        assert_eq!(status, StatusCode::OK);
1378        assert_eq!(body, "4:25:75");
1379    }
1380
1381    #[tokio::test]
1382    async fn extractor_clamps_size_over_max() {
1383        let (status, body) = fetch("/items?page=1&size=5000").await;
1384        assert_eq!(status, StatusCode::OK);
1385        assert_eq!(body, format!("1:{MAX_PAGE_SIZE}:0"));
1386    }
1387
1388    #[tokio::test]
1389    async fn extractor_coerces_page_zero_to_one() {
1390        let (status, body) = fetch("/items?page=0&size=10").await;
1391        assert_eq!(status, StatusCode::OK);
1392        assert_eq!(body, "1:10:0");
1393    }
1394
1395    // ── Malformed input handling ───────────────────────────────
1396    //
1397    // A list endpoint should never 400 because of a malformed pager.
1398    // These cases used to reject through `Query::from_request_parts` —
1399    // they now fall back to defaults.
1400
1401    #[tokio::test]
1402    async fn extractor_ignores_non_numeric_page() {
1403        let (status, body) = fetch("/items?page=abc&size=10").await;
1404        assert_eq!(status, StatusCode::OK);
1405        assert_eq!(body, "1:10:0");
1406    }
1407
1408    #[tokio::test]
1409    async fn extractor_ignores_empty_size() {
1410        let (status, body) = fetch("/items?page=2&size=").await;
1411        assert_eq!(status, StatusCode::OK);
1412        assert_eq!(body, format!("2:{DEFAULT_PAGE_SIZE}:{DEFAULT_PAGE_SIZE}"));
1413    }
1414
1415    #[tokio::test]
1416    async fn extractor_uses_last_value_on_duplicate_keys() {
1417        let (status, body) = fetch("/items?page=1&page=4&size=5").await;
1418        assert_eq!(status, StatusCode::OK);
1419        assert_eq!(body, "4:5:15");
1420    }
1421
1422    #[tokio::test]
1423    async fn extractor_ignores_unknown_keys() {
1424        let (status, body) = fetch("/items?sort=name&page=2&size=10").await;
1425        assert_eq!(status, StatusCode::OK);
1426        assert_eq!(body, "2:10:10");
1427    }
1428
1429    #[tokio::test]
1430    async fn extractor_handles_percent_encoded_values() {
1431        // `%32` decodes to `2`
1432        let (status, body) = fetch("/items?page=%32&size=10").await;
1433        assert_eq!(status, StatusCode::OK);
1434        assert_eq!(body, "2:10:10");
1435    }
1436
1437    #[tokio::test]
1438    async fn extractor_handles_negative_page_value() {
1439        // `-1` is not a valid u32 — fall back to the default page.
1440        let (status, body) = fetch("/items?page=-1&size=10").await;
1441        assert_eq!(status, StatusCode::OK);
1442        assert_eq!(body, "1:10:0");
1443    }
1444
1445    // ── ListQuery parsing / coercion ───────────────────────────
1446
1447    #[test]
1448    fn sort_dir_from_param_defaults_to_asc() {
1449        assert_eq!(SortDir::from_param("desc"), SortDir::Desc);
1450        assert_eq!(SortDir::from_param("DESC"), SortDir::Desc);
1451        assert_eq!(SortDir::from_param("asc"), SortDir::Asc);
1452        // Anything unrecognized falls back to Asc — never an error.
1453        assert_eq!(SortDir::from_param(""), SortDir::Asc);
1454        assert_eq!(SortDir::from_param("descending"), SortDir::Asc);
1455        assert_eq!(SortDir::from_param("id;DROP TABLE"), SortDir::Asc);
1456    }
1457
1458    #[test]
1459    fn list_query_default_is_empty() {
1460        let q = ListQuery::default();
1461        assert!(q.sort().is_none());
1462        assert_eq!(q.direction(), SortDir::Asc);
1463        assert_eq!(q.filters().count(), 0);
1464        assert!(q.is_empty());
1465    }
1466
1467    #[test]
1468    fn list_query_parses_sort_dir_and_filters() {
1469        let q = parse_list_query("sort=title&dir=desc&filter[published]=true&filter[author]=ada");
1470        assert_eq!(q.sort(), Some("title"));
1471        assert_eq!(q.direction(), SortDir::Desc);
1472        let filters: Vec<(&str, &str)> = q.filters().collect();
1473        assert_eq!(filters, vec![("published", "true"), ("author", "ada")]);
1474        assert!(!q.is_empty());
1475    }
1476
1477    #[test]
1478    fn list_query_ignores_pagination_and_unknown_keys() {
1479        // page/size belong to PageRequest; unknown keys are dropped.
1480        let q = parse_list_query("page=3&size=10&whatever=x&sort=name");
1481        assert_eq!(q.sort(), Some("name"));
1482        assert_eq!(q.filters().count(), 0);
1483    }
1484
1485    #[test]
1486    fn list_query_empty_sort_and_bad_dir_fall_back() {
1487        // Empty sort → None (default order); unrecognized dir → Asc.
1488        let q = parse_list_query("sort=&dir=sideways");
1489        assert!(q.sort().is_none());
1490        assert_eq!(q.direction(), SortDir::Asc);
1491    }
1492
1493    #[test]
1494    fn list_query_drops_empty_filter_column() {
1495        let q = parse_list_query("filter[]=orphan&filter[ok]=1");
1496        let filters: Vec<(&str, &str)> = q.filters().collect();
1497        assert_eq!(filters, vec![("ok", "1")]);
1498    }
1499
1500    #[test]
1501    fn list_query_last_sort_wins() {
1502        let q = parse_list_query("sort=a&sort=b");
1503        assert_eq!(q.sort(), Some("b"));
1504    }
1505
1506    #[test]
1507    fn list_query_carries_injection_payload_verbatim() {
1508        // The extractor does NOT sanitize — it faithfully carries the raw
1509        // request. The *allowlist* in the generated `list()` is what makes
1510        // this inert (the key is not a column, so it never reaches SQL).
1511        let q = parse_list_query("sort=id;DROP%20TABLE%20users");
1512        assert_eq!(q.sort(), Some("id;DROP TABLE users"));
1513    }
1514
1515    async fn list_echo(list: ListQuery) -> String {
1516        let filters: Vec<String> = list.filters().map(|(k, v)| format!("{k}={v}")).collect();
1517        format!(
1518            "{}:{}:{}",
1519            list.sort().unwrap_or("<none>"),
1520            list.direction().param_value(),
1521            filters.join(",")
1522        )
1523    }
1524
1525    async fn fetch_list(uri: &str) -> (StatusCode, String) {
1526        let app = Router::new().route("/items", get(list_echo));
1527        let res = app
1528            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1529            .await
1530            .unwrap();
1531        let status = res.status();
1532        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1533            .await
1534            .unwrap();
1535        (status, String::from_utf8(bytes.to_vec()).unwrap())
1536    }
1537
1538    #[tokio::test]
1539    async fn list_query_extractor_never_400s_on_garbage() {
1540        // Malicious/garbage input is carried verbatim, request still 200s.
1541        let (status, body) = fetch_list("/items?sort=id;DROP+TABLE&dir=%00&filter[x]=1").await;
1542        assert_eq!(status, StatusCode::OK);
1543        assert_eq!(body, "id;DROP TABLE:asc:x=1");
1544    }
1545
1546    #[tokio::test]
1547    async fn list_query_extractor_defaults_when_query_missing() {
1548        let (status, body) = fetch_list("/items").await;
1549        assert_eq!(status, StatusCode::OK);
1550        assert_eq!(body, "<none>:asc:");
1551    }
1552
1553    // ── Cursor: base64url round-trip ───────────────────────────
1554
1555    #[test]
1556    fn base64url_encode_known_vectors() {
1557        // RFC 4648 §10 vectors, with `=` padding stripped (we use base64url-no-pad).
1558        assert_eq!(base64url_encode(b""), "");
1559        assert_eq!(base64url_encode(b"f"), "Zg");
1560        assert_eq!(base64url_encode(b"fo"), "Zm8");
1561        assert_eq!(base64url_encode(b"foo"), "Zm9v");
1562        assert_eq!(base64url_encode(b"foob"), "Zm9vYg");
1563        assert_eq!(base64url_encode(b"fooba"), "Zm9vYmE");
1564        assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1565    }
1566
1567    #[test]
1568    fn base64url_uses_url_safe_alphabet() {
1569        // Bytes 0xFB, 0xEF would produce `+` and `/` in standard base64.
1570        // The url-safe alphabet uses `-` and `_` instead.
1571        let encoded = base64url_encode(&[0xFB, 0xEF, 0xFF]);
1572        assert!(!encoded.contains('+'));
1573        assert!(!encoded.contains('/'));
1574        assert!(encoded.contains('-') || encoded.contains('_'));
1575    }
1576
1577    #[test]
1578    fn base64url_round_trip_arbitrary_bytes() {
1579        for len in 0_u8..=32 {
1580            let input: Vec<u8> = (0..len).map(|i| i.wrapping_mul(37)).collect();
1581            let encoded = base64url_encode(&input);
1582            let decoded = base64url_decode(&encoded).unwrap();
1583            assert_eq!(decoded, input, "round-trip failed at len {len}");
1584        }
1585    }
1586
1587    #[test]
1588    fn base64url_decode_rejects_invalid_chars() {
1589        assert_eq!(base64url_decode("!!!!"), None);
1590        assert_eq!(base64url_decode("AAAA="), None); // padding not accepted
1591        assert_eq!(base64url_decode("AAA+"), None); // standard-alphabet char
1592    }
1593
1594    #[test]
1595    fn base64url_decode_rejects_one_trailing_char() {
1596        // A single base64 char carries only 6 bits — not enough for a byte.
1597        assert_eq!(base64url_decode("A"), None);
1598        assert_eq!(base64url_decode("ZmA"), Some(vec![0x66, 0x60]));
1599    }
1600
1601    // ── Cursor: encode/decode ──────────────────────────────────
1602
1603    #[derive(Serialize, Deserialize, PartialEq, Debug)]
1604    struct PostKey {
1605        created_at: String,
1606        id: i64,
1607    }
1608
1609    #[test]
1610    fn cursor_round_trip_preserves_payload() {
1611        let key = PostKey {
1612            created_at: "2026-04-27T12:00:00Z".to_string(),
1613            id: 12_345,
1614        };
1615        let token = Cursor::encode(&key).unwrap();
1616        let decoded: PostKey = Cursor::decode(&token).unwrap();
1617        assert_eq!(decoded, key);
1618    }
1619
1620    #[test]
1621    fn cursor_token_is_url_safe() {
1622        // Pick a payload that contains JSON characters which would
1623        // otherwise need percent-encoding (`{`, `}`, `:`, `"`).
1624        let key = PostKey {
1625            created_at: "2026-04-27T12:00:00Z".to_string(),
1626            id: 1,
1627        };
1628        let token = Cursor::encode(&key).unwrap();
1629        // Only chars from the base64url alphabet — no `+`, `/`, `=`, `{`, `:`.
1630        assert!(
1631            token
1632                .bytes()
1633                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1634        );
1635    }
1636
1637    #[test]
1638    fn cursor_decode_returns_none_for_garbage() {
1639        // Not base64url at all.
1640        let decoded: Option<PostKey> = Cursor::decode("!!!not a token!!!");
1641        assert!(decoded.is_none());
1642    }
1643
1644    #[test]
1645    fn cursor_decode_returns_none_for_wrong_schema() {
1646        // Valid base64url, valid JSON, but doesn't match the target type.
1647        let other = serde_json::json!({"unrelated": "value"});
1648        let token = Cursor::encode(&other).unwrap();
1649        let decoded: Option<PostKey> = Cursor::decode(&token);
1650        assert!(decoded.is_none());
1651    }
1652
1653    // ── CursorRequest coercion ─────────────────────────────────
1654
1655    #[test]
1656    fn cursor_request_defaults_when_empty() {
1657        let r = CursorRequest::default();
1658        assert!(r.cursor().is_none());
1659        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1660        assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1661        assert_eq!(r.fetch_limit(), i64::from(DEFAULT_PAGE_SIZE) + 1);
1662    }
1663
1664    #[test]
1665    fn cursor_request_clamps_size_to_max() {
1666        let r = CursorRequest::new(None, 9_999);
1667        assert_eq!(r.size(), MAX_PAGE_SIZE);
1668        assert_eq!(r.fetch_limit(), i64::from(MAX_PAGE_SIZE) + 1);
1669
1670        let exact = CursorRequest::new(None, MAX_PAGE_SIZE);
1671        assert_eq!(exact.size(), MAX_PAGE_SIZE);
1672
1673        let over = CursorRequest::new(None, MAX_PAGE_SIZE + 1);
1674        assert_eq!(over.size(), MAX_PAGE_SIZE);
1675
1676        let under = CursorRequest::new(None, MAX_PAGE_SIZE - 1);
1677        assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1678    }
1679
1680    #[test]
1681    fn cursor_request_zero_size_falls_back_to_default() {
1682        let r = CursorRequest::new(None, 0);
1683        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1684    }
1685
1686    #[test]
1687    fn cursor_request_decode_helper_returns_none_when_missing() {
1688        let r = CursorRequest::default();
1689        let decoded: Option<PostKey> = r.decode();
1690        assert!(decoded.is_none());
1691    }
1692
1693    #[test]
1694    fn cursor_request_decode_helper_round_trips() {
1695        let key = PostKey {
1696            created_at: "2026-04-27T00:00:00Z".to_string(),
1697            id: 7,
1698        };
1699        let token = Cursor::encode(&key).unwrap();
1700        let r = CursorRequest::new(Some(token), 10);
1701        let decoded: PostKey = r.decode().unwrap();
1702        assert_eq!(decoded, key);
1703    }
1704
1705    // ── CursorPage from_overfetched ────────────────────────────
1706
1707    #[test]
1708    fn cursor_page_signals_no_next_when_under_limit() {
1709        let req = CursorRequest::new(None, 5);
1710        let items = vec![1_i32, 2, 3]; // fewer than size
1711        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1712        assert_eq!(page.content, vec![1, 2, 3]);
1713        assert!(!page.has_next);
1714        assert!(page.next_cursor.is_none());
1715        assert_eq!(page.size, 5);
1716    }
1717
1718    #[test]
1719    fn cursor_page_signals_no_next_at_exact_limit() {
1720        let req = CursorRequest::new(None, 3);
1721        // Caller fetched limit+1 = 4, but only got 3 — last page.
1722        let items = vec![1_i32, 2, 3];
1723        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1724        assert_eq!(page.content.len(), 3);
1725        assert!(!page.has_next);
1726        assert!(page.next_cursor.is_none());
1727    }
1728
1729    #[test]
1730    fn cursor_page_truncates_overflow_and_emits_next_cursor() {
1731        let req = CursorRequest::new(None, 3);
1732        // Caller fetched limit+1 = 4 rows.
1733        let items = vec![1_i32, 2, 3, 4];
1734        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1735        // Only `size` items kept.
1736        assert_eq!(page.content, vec![1, 2, 3]);
1737        assert!(page.has_next);
1738        let token = page.next_cursor.as_ref().expect("next cursor present");
1739        // Cursor encodes the *last kept* row (id=3), not the popped row (id=4).
1740        // This matters: the next page filters with a strict inequality
1741        // against this id, which keeps zero-duplicate behaviour even
1742        // if a new row gets inserted between page boundaries.
1743        let decoded: serde_json::Value = Cursor::decode(token).unwrap();
1744        assert_eq!(decoded, serde_json::json!({"id": 3}));
1745    }
1746
1747    #[test]
1748    fn cursor_page_from_overfetched_handles_encoding_failure() {
1749        let req = CursorRequest::new(None, 2);
1750        let items = vec![1_i32, 2, 3];
1751        let page = CursorPage::from_overfetched_inner(
1752            items,
1753            &req,
1754            |&n| n,
1755            |_| None::<String>, // Force encoding to fail
1756        );
1757
1758        assert_eq!(page.content, vec![1, 2]);
1759        assert_eq!(page.size, 2);
1760        assert!(page.next_cursor.is_none());
1761        assert!(!page.has_next);
1762    }
1763
1764    #[test]
1765    fn cursor_page_empty_helper() {
1766        let req = CursorRequest::new(None, 10);
1767        let page: CursorPage<i32> = CursorPage::empty(&req);
1768        assert!(page.content.is_empty());
1769        assert_eq!(page.size, 10);
1770        assert!(!page.has_next);
1771        assert!(page.next_cursor.is_none());
1772
1773        let req_diff_size = CursorRequest::new(None, 5);
1774        let page_diff_size: CursorPage<i32> = CursorPage::empty(&req_diff_size);
1775        assert_eq!(page_diff_size.size, 5);
1776    }
1777
1778    #[test]
1779    fn cursor_page_map_preserves_metadata() {
1780        let req = CursorRequest::new(None, 2);
1781        let items = vec![1_i32, 2, 3]; // overfetch by 1
1782        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1783
1784        let original_cursor = page.next_cursor.clone();
1785
1786        let mapped = page.map(|n| n.to_string());
1787        assert_eq!(mapped.content, vec!["1", "2"]);
1788        assert!(mapped.has_next);
1789        assert_eq!(mapped.next_cursor, original_cursor);
1790        assert_eq!(mapped.size, 2);
1791    }
1792
1793    #[test]
1794    fn cursor_page_serializes_to_expected_shape() {
1795        let req = CursorRequest::new(None, 2);
1796        let items = vec!["a", "b", "c"];
1797        let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1798        let json = serde_json::to_value(&page).unwrap();
1799        assert_eq!(json["size"], 2);
1800        assert_eq!(json["has_next"], true);
1801        assert!(json["next_cursor"].is_string());
1802        assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1803    }
1804
1805    #[test]
1806    fn cursor_page_last_page_serializes_null_cursor() {
1807        let req = CursorRequest::new(None, 5);
1808        let items = vec!["only"];
1809        let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1810        let json = serde_json::to_value(&page).unwrap();
1811        assert_eq!(json["has_next"], false);
1812        assert!(json["next_cursor"].is_null());
1813    }
1814
1815    // ── CursorRequest extractor ────────────────────────────────
1816
1817    async fn cursor_echo(req: CursorRequest) -> String {
1818        format!(
1819            "{}|{}|{}",
1820            req.cursor().unwrap_or("-"),
1821            req.size(),
1822            req.fetch_limit(),
1823        )
1824    }
1825
1826    async fn fetch_cursor(uri: &str) -> (StatusCode, String) {
1827        let app = Router::new().route("/feed", get(cursor_echo));
1828        let res = app
1829            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1830            .await
1831            .unwrap();
1832        let status = res.status();
1833        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1834            .await
1835            .unwrap();
1836        (status, String::from_utf8(bytes.to_vec()).unwrap())
1837    }
1838
1839    #[tokio::test]
1840    async fn cursor_extractor_uses_defaults_when_query_missing() {
1841        let (status, body) = fetch_cursor("/feed").await;
1842        assert_eq!(status, StatusCode::OK);
1843        assert_eq!(
1844            body,
1845            format!("-|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1846        );
1847    }
1848
1849    #[tokio::test]
1850    async fn cursor_extractor_parses_cursor_and_size() {
1851        let (status, body) = fetch_cursor("/feed?cursor=abc123&size=5").await;
1852        assert_eq!(status, StatusCode::OK);
1853        assert_eq!(body, "abc123|5|6");
1854    }
1855
1856    #[tokio::test]
1857    async fn cursor_extractor_clamps_size_over_max() {
1858        let (status, body) = fetch_cursor("/feed?cursor=t&size=9999").await;
1859        assert_eq!(status, StatusCode::OK);
1860        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1861
1862        // Exact MAX_PAGE_SIZE
1863        let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={MAX_PAGE_SIZE}")).await;
1864        assert_eq!(status, StatusCode::OK);
1865        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1866
1867        // MAX_PAGE_SIZE + 1
1868        let size = MAX_PAGE_SIZE + 1;
1869        let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={size}")).await;
1870        assert_eq!(status, StatusCode::OK);
1871        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1872    }
1873
1874    #[tokio::test]
1875    async fn cursor_extractor_ignores_empty_cursor() {
1876        // Empty `cursor=` should be treated as "no cursor", not as `Some("")`.
1877        let (status, body) = fetch_cursor("/feed?cursor=&size=10").await;
1878        assert_eq!(status, StatusCode::OK);
1879        assert_eq!(body, "-|10|11");
1880    }
1881
1882    #[tokio::test]
1883    async fn cursor_extractor_does_not_400_on_malformed_size() {
1884        let (status, body) = fetch_cursor("/feed?cursor=t&size=abc").await;
1885        assert_eq!(status, StatusCode::OK);
1886        assert_eq!(
1887            body,
1888            format!("t|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn cursor_extractor_handles_percent_encoded_token() {
1894        // base64url tokens never need percent-encoding, but a paranoid
1895        // client might do it anyway. `%2D` decodes to `-`.
1896        let (status, body) = fetch_cursor("/feed?cursor=ab%2Dcd&size=2").await;
1897        assert_eq!(status, StatusCode::OK);
1898        assert_eq!(body, "ab-cd|2|3");
1899    }
1900
1901    // ── Concurrent-insert simulation ───────────────────────────
1902    //
1903    // The story's success metric is "zero duplicate items during
1904    // concurrent inserts". This test simulates a feed where a new row
1905    // arrives between the first and second page request, and verifies
1906    // the keyset filter still returns every original row exactly once.
1907
1908    #[test]
1909    fn concurrent_inserts_do_not_cause_duplicates() {
1910        // Sort by (created_at desc, id desc) — this is the recommended
1911        // tie-breaker pattern from the docs.
1912        #[derive(Clone, Debug, PartialEq, Eq)]
1913        struct Row {
1914            id: i64,
1915            created_at: i64,
1916        }
1917        #[derive(Serialize, Deserialize)]
1918        struct Key {
1919            created_at: i64,
1920            id: i64,
1921        }
1922
1923        let mut table: Vec<Row> = (1..=5)
1924            .map(|id| Row {
1925                id,
1926                created_at: 1_000 - id, // older as id grows
1927            })
1928            .collect();
1929        // Sort newest-first so id=1 is first.
1930        table.sort_by_key(|r| std::cmp::Reverse((r.created_at, r.id)));
1931
1932        // First request: no cursor, size=2.
1933        let req1 = CursorRequest::new(None, 2);
1934        let fetch1 = usize::try_from(req1.fetch_limit()).unwrap();
1935        let fetched1: Vec<Row> = table.iter().take(fetch1).cloned().collect();
1936        let page1 = CursorPage::from_overfetched(fetched1, &req1, |r| Key {
1937            created_at: r.created_at,
1938            id: r.id,
1939        });
1940        let cursor1 = page1.next_cursor.clone().expect("page 1 has next");
1941        assert_eq!(page1.content.len(), 2);
1942
1943        // ── Concurrent insert lands BEFORE the next request ──────
1944        // A new row is inserted with the highest created_at, so it
1945        // would appear on a fresh page 1 — but our cursor pagination
1946        // is keyset-based, so the second request must skip it.
1947        table.insert(
1948            0,
1949            Row {
1950                id: 99,
1951                created_at: 9_999,
1952            },
1953        );
1954
1955        // Second request: cursor from page 1, size=2.
1956        let req2 = CursorRequest::new(Some(cursor1), 2);
1957        let key: Key = req2.decode().unwrap();
1958        let fetch2 = usize::try_from(req2.fetch_limit()).unwrap();
1959
1960        // Apply the keyset filter: rows that come *after* the cursor
1961        // in the (created_at desc, id desc) ordering, then take the
1962        // overfetch window.
1963        let fetched2: Vec<Row> = table
1964            .iter()
1965            .filter(|r| {
1966                r.created_at < key.created_at || (r.created_at == key.created_at && r.id < key.id)
1967            })
1968            .take(fetch2)
1969            .cloned()
1970            .collect();
1971        let page2 = CursorPage::from_overfetched(fetched2, &req2, |r| Key {
1972            created_at: r.created_at,
1973            id: r.id,
1974        });
1975
1976        // Combine the two pages. No row should appear twice, and the
1977        // newly-inserted id=99 must NOT show up (the user already
1978        // scrolled past where it would have appeared).
1979        let mut all: Vec<Row> = page1.content;
1980        all.extend(page2.content);
1981        let mut ids: Vec<i64> = all.iter().map(|r| r.id).collect();
1982        let original_len = ids.len();
1983        ids.sort_unstable();
1984        ids.dedup();
1985        assert_eq!(ids.len(), original_len, "no duplicates across pages");
1986        assert!(
1987            !all.iter().any(|r| r.id == 99),
1988            "concurrently-inserted row not duplicated"
1989        );
1990    }
1991
1992    // ── Signed cursors ─────────────────────────────────────────
1993
1994    const TEST_KEY: &[u8] = b"test-signing-key-do-not-use-in-prod";
1995
1996    #[derive(Serialize, Deserialize, PartialEq, Debug)]
1997    struct ScopedCursor {
1998        tenant_id: i64,
1999        cursor_id: i64,
2000    }
2001
2002    #[test]
2003    fn signed_cursor_round_trip() {
2004        let payload = ScopedCursor {
2005            tenant_id: 42,
2006            cursor_id: 7,
2007        };
2008        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2009        // Token shape: <payload>.<sig>
2010        assert!(token.contains('.'));
2011        let decoded: ScopedCursor = Cursor::decode_signed(&token, TEST_KEY).unwrap();
2012        assert_eq!(decoded, payload);
2013    }
2014
2015    #[test]
2016    fn signed_cursor_rejects_tampered_payload() {
2017        let payload = ScopedCursor {
2018            tenant_id: 42,
2019            cursor_id: 7,
2020        };
2021        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2022        // Forge: re-encode a payload with tenant_id=99 but reuse the
2023        // original signature.
2024        let forged_payload = ScopedCursor {
2025            tenant_id: 99,
2026            cursor_id: 7,
2027        };
2028        let forged_b64 = base64url_encode(&serde_json::to_vec(&forged_payload).unwrap());
2029        let (_, sig_b64) = token.split_once('.').unwrap();
2030        let forged_token = format!("{forged_b64}.{sig_b64}");
2031        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&forged_token, TEST_KEY);
2032        assert!(decoded.is_none(), "tampered cursor must not verify");
2033    }
2034
2035    #[test]
2036    fn signed_cursor_rejects_wrong_key() {
2037        let payload = ScopedCursor {
2038            tenant_id: 42,
2039            cursor_id: 7,
2040        };
2041        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2042        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&token, b"different-key");
2043        assert!(decoded.is_none());
2044    }
2045
2046    #[test]
2047    fn signed_cursor_rejects_unsigned_token() {
2048        // A plain (unsigned) token should not verify against the
2049        // signed decoder — even though it's structurally valid JSON.
2050        let payload = ScopedCursor {
2051            tenant_id: 42,
2052            cursor_id: 7,
2053        };
2054        let unsigned = Cursor::encode(&payload).unwrap();
2055        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&unsigned, TEST_KEY);
2056        assert!(
2057            decoded.is_none(),
2058            "unsigned token must not pass signed verification"
2059        );
2060    }
2061
2062    #[test]
2063    fn signed_cursor_rejects_missing_signature_segment() {
2064        // Token without a `.` separator.
2065        let decoded: Option<ScopedCursor> = Cursor::decode_signed("just-some-bytes", TEST_KEY);
2066        assert!(decoded.is_none());
2067    }
2068
2069    #[test]
2070    fn signed_cursor_rejects_garbage() {
2071        let decoded: Option<ScopedCursor> = Cursor::decode_signed("!!!.!!!", TEST_KEY);
2072        assert!(decoded.is_none());
2073    }
2074
2075    #[test]
2076    fn cursor_request_decode_signed_returns_none_when_missing() {
2077        let r = CursorRequest::default();
2078        let decoded: Option<ScopedCursor> = r.decode_signed(TEST_KEY);
2079        assert!(decoded.is_none());
2080    }
2081
2082    #[test]
2083    fn cursor_request_decode_signed_round_trips() {
2084        let payload = ScopedCursor {
2085            tenant_id: 42,
2086            cursor_id: 7,
2087        };
2088        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2089        let r = CursorRequest::new(Some(token), 10);
2090        let decoded: ScopedCursor = r.decode_signed(TEST_KEY).unwrap();
2091        assert_eq!(decoded, payload);
2092    }
2093
2094    #[test]
2095    fn cursor_page_from_overfetched_signed_emits_signed_token() {
2096        let req = CursorRequest::new(None, 2);
2097        let items = vec![1_i32, 2, 3]; // overfetch by 1
2098        let page = CursorPage::from_overfetched_signed(items, &req, TEST_KEY, |&n| ScopedCursor {
2099            tenant_id: 42,
2100            cursor_id: i64::from(n),
2101        });
2102        assert!(page.has_next);
2103        let token = page.next_cursor.as_ref().unwrap();
2104        assert!(token.contains('.'), "signed token format is payload.sig");
2105        // Round-trip through the signed decoder.
2106        let key: ScopedCursor = Cursor::decode_signed(token, TEST_KEY).unwrap();
2107        assert_eq!(key.cursor_id, 2); // boundary = last kept item
2108        // Plain decoder must NOT happen to extract a structurally
2109        // valid value, because the token contains the signature suffix.
2110        let mishandled: Option<ScopedCursor> = Cursor::decode(token);
2111        assert!(mishandled.is_none());
2112    }
2113
2114    #[test]
2115    fn signed_cursor_signature_is_constant_time_compared() {
2116        // Smoke test: same key, same input → identical sig. Different
2117        // key → different sig. (Constant-time-ness itself is not
2118        // observable from this test; we're just exercising the path.)
2119        let p = ScopedCursor {
2120            tenant_id: 1,
2121            cursor_id: 1,
2122        };
2123        let a = Cursor::encode_signed(&p, b"k1").unwrap();
2124        let b = Cursor::encode_signed(&p, b"k1").unwrap();
2125        let c = Cursor::encode_signed(&p, b"k2").unwrap();
2126        assert_eq!(a, b);
2127        assert_ne!(a, c);
2128    }
2129
2130    #[tokio::test]
2131    async fn page_request_extractor_defaults_on_missing_uri_query() {
2132        use axum::extract::FromRequestParts;
2133        use axum::http::Request;
2134        let req = Request::builder().uri("/").body(()).unwrap();
2135        let (mut parts, ()) = req.into_parts();
2136
2137        let extracted = PageRequest::from_request_parts(&mut parts, &())
2138            .await
2139            .unwrap();
2140
2141        assert_eq!(extracted.page(), 1);
2142        assert_eq!(extracted.size(), 20); // Default is 20
2143    }
2144
2145    #[tokio::test]
2146    async fn cursor_extractor_defaults_on_missing_uri_query() {
2147        use axum::extract::FromRequestParts;
2148        use axum::http::Request;
2149        let req = Request::builder().uri("/").body(()).unwrap();
2150        let (mut parts, ()) = req.into_parts();
2151
2152        let extracted = CursorRequest::from_request_parts(&mut parts, &())
2153            .await
2154            .unwrap();
2155
2156        assert_eq!(extracted.size(), 20); // Default is 20
2157        assert!(extracted.cursor.is_none());
2158    }
2159
2160    #[test]
2161    fn base64url_encode_pad_branch() {
2162        // 2 byte string leads to rem.len() == 2, which exercises the second padding path.
2163        let encoded = base64url_encode(b"ab");
2164        assert_eq!(encoded, "YWI");
2165    }
2166
2167    #[test]
2168    fn base64url_decode_pad_branch() {
2169        // Try to decode exactly a length that produces rem 3.
2170        // YWI decodes to 'ab' (2 bytes)
2171        let decoded = base64url_decode("YWI").unwrap();
2172        assert_eq!(decoded, b"ab");
2173    }
2174
2175    #[test]
2176    fn cursor_encode_fails_gracefully_on_serialization_error() {
2177        use serde::Serialize;
2178
2179        struct FailToSerialize;
2180
2181        impl Serialize for FailToSerialize {
2182            fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2183            where
2184                S: serde::Serializer,
2185            {
2186                Err(serde::ser::Error::custom("forced failure"))
2187            }
2188        }
2189
2190        let res = Cursor::encode(&FailToSerialize);
2191        assert!(res.is_err());
2192
2193        let res_signed = Cursor::encode_signed(&FailToSerialize, b"key");
2194        assert!(res_signed.is_err());
2195    }
2196
2197    // ── IntoResponse + RFC 8288 Link headers ────────────────────
2198
2199    #[tokio::test]
2200    async fn page_into_response_emits_envelope_and_link_headers() {
2201        async fn handler() -> Page<u32> {
2202            // Page 2 of 7 (137 items, size 20): prev + next both exist.
2203            Page::new((21..=40).collect(), 137, &PageRequest::new(2, 20))
2204        }
2205        let app = Router::new().route("/items", get(handler));
2206        let res = app
2207            .oneshot(
2208                Request::builder()
2209                    .uri("/items")
2210                    .body(Body::empty())
2211                    .unwrap(),
2212            )
2213            .await
2214            .unwrap();
2215
2216        assert_eq!(res.status(), StatusCode::OK);
2217        let link = res
2218            .headers()
2219            .get(header::LINK)
2220            .expect("Link header must be present")
2221            .to_str()
2222            .unwrap()
2223            .to_owned();
2224        assert!(link.contains("<?page=1&size=20>; rel=\"first\""), "{link}");
2225        assert!(
2226            link.contains("<?page=1&size=20>; rel=\"prev\""),
2227            "prev must point to page 1: {link}"
2228        );
2229        assert!(link.contains("<?page=3&size=20>; rel=\"next\""), "{link}");
2230        assert!(link.contains("<?page=7&size=20>; rel=\"last\""), "{link}");
2231
2232        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2233            .await
2234            .unwrap();
2235        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2236        assert_eq!(json["page"], 2);
2237        assert_eq!(json["size"], 20);
2238        assert_eq!(json["total_elements"], 137);
2239        assert_eq!(json["total_pages"], 7);
2240        assert_eq!(json["content"].as_array().unwrap().len(), 20);
2241    }
2242
2243    #[tokio::test]
2244    async fn first_page_has_no_prev_link() {
2245        async fn handler() -> Page<u32> {
2246            Page::new((1..=20).collect(), 40, &PageRequest::new(1, 20))
2247        }
2248        let app = Router::new().route("/items", get(handler));
2249        let res = app
2250            .oneshot(
2251                Request::builder()
2252                    .uri("/items")
2253                    .body(Body::empty())
2254                    .unwrap(),
2255            )
2256            .await
2257            .unwrap();
2258        let link = res
2259            .headers()
2260            .get(header::LINK)
2261            .unwrap()
2262            .to_str()
2263            .unwrap()
2264            .to_owned();
2265        assert!(
2266            !link.contains("rel=\"prev\""),
2267            "page 1 must not advertise prev: {link}"
2268        );
2269        assert!(link.contains("rel=\"next\""), "{link}");
2270        assert!(link.contains("rel=\"first\""), "{link}");
2271        assert!(link.contains("rel=\"last\""), "{link}");
2272    }
2273
2274    #[test]
2275    fn empty_page_last_link_clamps_to_one() {
2276        // An empty collection has `total_pages == 0`. The `last` link must
2277        // still target a valid 1-indexed page (`page=1`), never `page=0`.
2278        let link = page_link_header_value(1, 20, 0, false);
2279        assert!(
2280            link.contains("<?page=1&size=20>; rel=\"last\""),
2281            "last link must clamp to page 1 for an empty collection: {link}"
2282        );
2283        assert!(
2284            !link.contains("page=0"),
2285            "no link may reference the invalid page 0: {link}"
2286        );
2287    }
2288
2289    #[tokio::test]
2290    async fn cursor_page_emits_next_link_only() {
2291        async fn handler() -> CursorPage<u32> {
2292            // Over-fetch: request size 2, 3 rows returned → has_next, next_cursor set.
2293            CursorPage::from_overfetched(vec![1, 2, 3], &CursorRequest::new(None, 2), |&n| n)
2294        }
2295        let app = Router::new().route("/feed", get(handler));
2296        let res = app
2297            .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2298            .await
2299            .unwrap();
2300        let link = res
2301            .headers()
2302            .get(header::LINK)
2303            .expect("cursor page with a next page must emit a Link")
2304            .to_str()
2305            .unwrap()
2306            .to_owned();
2307        assert!(link.contains("rel=\"next\""), "{link}");
2308        assert!(
2309            link.contains("cursor="),
2310            "next link must carry the cursor token: {link}"
2311        );
2312        assert!(
2313            !link.contains("rel=\"prev\""),
2314            "keyset pagination has no prev: {link}"
2315        );
2316    }
2317
2318    #[tokio::test]
2319    async fn cursor_page_last_page_has_no_link() {
2320        async fn handler() -> CursorPage<u32> {
2321            // Fewer rows than the page size → last page, no next cursor.
2322            CursorPage::from_overfetched(vec![1, 2], &CursorRequest::new(None, 5), |&n| n)
2323        }
2324        let app = Router::new().route("/feed", get(handler));
2325        let res = app
2326            .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2327            .await
2328            .unwrap();
2329        assert!(
2330            res.headers().get(header::LINK).is_none(),
2331            "last cursor page must not advertise a next link"
2332        );
2333    }
2334
2335    #[test]
2336    fn paginate_in_memory_windows_and_counts() {
2337        let all: Vec<u32> = (1..=25).collect();
2338        let page = Page::paginate_in_memory(all, &PageRequest::new(2, 10));
2339        assert_eq!(page.page, 2);
2340        assert_eq!(page.size, 10);
2341        assert_eq!(page.total_elements, 25);
2342        assert_eq!(page.total_pages, 3);
2343        assert_eq!(page.content, (11..=20).collect::<Vec<_>>());
2344        assert!(page.has_next);
2345        assert!(page.has_previous);
2346    }
2347}