1use axum::extract::FromRequestParts;
143use axum::http::request::Parts;
144use axum::http::{HeaderValue, header};
145use axum::response::{IntoResponse, Response};
146use serde::de::DeserializeOwned;
147use serde::{Deserialize, Serialize};
148
149pub const DEFAULT_PAGE_SIZE: u32 = 20;
151
152pub const MAX_PAGE_SIZE: u32 = 100;
155
156#[derive(Debug, Clone, Copy, Default, Deserialize)]
204pub struct PageRequest {
205 #[serde(default)]
206 page: Option<u32>,
207 #[serde(default)]
208 size: Option<u32>,
209}
210
211impl PageRequest {
212 #[must_use]
215 pub const fn new(page: u32, size: u32) -> Self {
216 Self {
217 page: Some(page),
218 size: Some(size),
219 }
220 }
221
222 #[must_use]
224 pub const fn page(&self) -> u32 {
225 match self.page {
226 Some(p) if p >= 1 => p,
227 _ => 1,
228 }
229 }
230
231 #[must_use]
233 pub const fn size(&self) -> u32 {
234 match self.size {
235 Some(0) | None => DEFAULT_PAGE_SIZE,
236 Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
237 Some(s) => s,
238 }
239 }
240
241 #[must_use]
243 pub const fn limit(&self) -> i64 {
244 self.size() as i64
245 }
246
247 #[must_use]
249 pub const fn offset(&self) -> i64 {
250 ((self.page() - 1) as i64) * (self.size() as i64)
251 }
252}
253
254impl<S> FromRequestParts<S> for PageRequest
255where
256 S: Send + Sync,
257{
258 type Rejection = std::convert::Infallible;
259
260 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
261 Ok(parts.uri.query().map_or_else(Self::default, parse_query))
266 }
267}
268
269fn parse_query(query: &str) -> PageRequest {
274 let mut req = PageRequest::default();
275 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
276 match key.as_ref() {
277 "page" => {
278 if let Ok(n) = value.parse::<u32>() {
279 req.page = Some(n);
280 }
281 }
282 "size" => {
283 if let Ok(n) = value.parse::<u32>() {
284 req.size = Some(n);
285 }
286 }
287 _ => {}
288 }
289 }
290 req
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub enum SortDir {
307 #[default]
309 Asc,
310 Desc,
312}
313
314impl SortDir {
315 #[must_use]
317 pub const fn param_value(self) -> &'static str {
318 match self {
319 Self::Asc => "asc",
320 Self::Desc => "desc",
321 }
322 }
323
324 #[must_use]
326 pub const fn aria_value(self) -> &'static str {
327 match self {
328 Self::Asc => "ascending",
329 Self::Desc => "descending",
330 }
331 }
332
333 #[must_use]
335 pub const fn toggled(self) -> Self {
336 match self {
337 Self::Asc => Self::Desc,
338 Self::Desc => Self::Asc,
339 }
340 }
341
342 #[must_use]
347 pub fn from_param(value: &str) -> Self {
348 if value.eq_ignore_ascii_case("desc") {
349 Self::Desc
350 } else {
351 Self::Asc
352 }
353 }
354}
355
356#[derive(Debug, Clone, Default)]
406pub struct ListQuery {
407 sort: Option<String>,
408 dir: SortDir,
409 filters: Vec<(String, String)>,
410}
411
412impl ListQuery {
413 #[must_use]
416 pub fn new(sort: Option<&str>, dir: SortDir, filters: &[(&str, &str)]) -> Self {
417 Self {
418 sort: sort.map(str::to_owned),
419 dir,
420 filters: filters
421 .iter()
422 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
423 .collect(),
424 }
425 }
426
427 #[must_use]
434 pub fn sort(&self) -> Option<&str> {
435 self.sort.as_deref()
436 }
437
438 #[must_use]
440 pub const fn direction(&self) -> SortDir {
441 self.dir
442 }
443
444 pub fn filters(&self) -> impl Iterator<Item = (&str, &str)> {
450 self.filters.iter().map(|(k, v)| (k.as_str(), v.as_str()))
451 }
452
453 #[must_use]
455 pub fn is_empty(&self) -> bool {
456 self.sort.is_none() && self.dir == SortDir::Asc && self.filters.is_empty()
457 }
458}
459
460#[doc(hidden)]
472pub trait AutumnListable {
473 #[doc(hidden)]
474 fn __autumn_list_apply_order<Q>(query_builder: Q, _list: &ListQuery) -> Q {
475 query_builder
476 }
477 #[doc(hidden)]
478 fn __autumn_list_apply_filters<Q>(query_builder: Q, _list: &ListQuery) -> Q {
479 query_builder
480 }
481}
482
483impl<T> AutumnListable for T {}
484
485impl<S> FromRequestParts<S> for ListQuery
486where
487 S: Send + Sync,
488{
489 type Rejection = std::convert::Infallible;
490
491 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
492 Ok(parts
495 .uri
496 .query()
497 .map_or_else(Self::default, parse_list_query))
498 }
499}
500
501fn parse_list_query(query: &str) -> ListQuery {
513 let mut req = ListQuery::default();
514 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
515 let key = key.as_ref();
516 if key == "sort" {
517 if value.is_empty() {
518 req.sort = None;
519 } else {
520 req.sort = Some(value.into_owned());
521 }
522 } else if key == "dir" {
523 req.dir = SortDir::from_param(&value);
524 } else if let Some(col) = key
525 .strip_prefix("filter[")
526 .and_then(|rest| rest.strip_suffix(']'))
527 && !col.is_empty()
528 {
529 req.filters.push((col.to_owned(), value.into_owned()));
530 }
531 }
532 req
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct Page<T> {
561 pub content: Vec<T>,
563 pub page: u32,
565 pub size: u32,
567 pub total_elements: u64,
569 pub total_pages: u32,
571 pub has_next: bool,
573 pub has_previous: bool,
575}
576
577impl<T> Page<T> {
578 #[must_use]
584 pub fn new(items: Vec<T>, total: i64, request: &PageRequest) -> Self {
585 let size = request.size();
586 let page = request.page();
587 let total_elements = u64::try_from(total).unwrap_or(0);
588
589 let total_pages = if total_elements == 0 {
593 1
594 } else {
595 u32::try_from(total_elements.div_ceil(u64::from(size))).unwrap_or(u32::MAX)
597 };
598
599 Self {
600 content: items,
601 page,
602 size,
603 total_elements,
604 total_pages,
605 has_next: page < total_pages,
606 has_previous: page > 1,
607 }
608 }
609
610 #[must_use]
614 pub fn empty(request: &PageRequest) -> Self {
615 Self::new(Vec::new(), 0, request)
616 }
617
618 #[must_use]
626 pub fn from_raw(page: u32, size: u32, total_elements: u64, total_pages: u32) -> Self {
627 let page = page.max(1);
628 let total_pages = total_pages.max(1);
629 Self {
630 content: Vec::new(),
631 page,
632 size,
633 total_elements,
634 total_pages,
635 has_next: page < total_pages,
636 has_previous: page > 1,
637 }
638 }
639
640 #[must_use]
653 pub fn paginate_in_memory(all: Vec<T>, request: &PageRequest) -> Self {
654 let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
655 let offset = usize::try_from(request.offset()).unwrap_or(usize::MAX);
656 let size = request.size() as usize;
657 let content: Vec<T> = all.into_iter().skip(offset).take(size).collect();
658 Self::new(content, total, request)
659 }
660
661 pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
666 Page {
667 content: self.content.into_iter().map(f).collect(),
668 page: self.page,
669 size: self.size,
670 total_elements: self.total_elements,
671 total_pages: self.total_pages,
672 has_next: self.has_next,
673 has_previous: self.has_previous,
674 }
675 }
676}
677
678fn page_link_header_value(page: u32, size: u32, total_pages: u32, has_next: bool) -> String {
685 let mut links: Vec<String> = Vec::with_capacity(4);
686 let rel = |target: u32, name: &str| format!("<?page={target}&size={size}>; rel=\"{name}\"");
687 links.push(rel(1, "first"));
688 if page > 1 {
689 links.push(rel(page - 1, "prev"));
690 }
691 if has_next {
692 links.push(rel(page + 1, "next"));
693 }
694 let last_page = total_pages.max(1);
697 links.push(rel(last_page, "last"));
698 links.join(", ")
699}
700
701impl<T> IntoResponse for Page<T>
702where
703 T: Serialize,
704{
705 fn into_response(self) -> Response {
706 let (page, size, total_pages, has_next) =
708 (self.page, self.size, self.total_pages, self.has_next);
709 let mut response = axum::Json(self).into_response();
710 if let Ok(value) =
711 HeaderValue::from_str(&page_link_header_value(page, size, total_pages, has_next))
712 {
713 response.headers_mut().insert(header::LINK, value);
714 }
715 response
716 }
717}
718
719impl<T> IntoResponse for CursorPage<T>
720where
721 T: Serialize,
722{
723 fn into_response(self) -> Response {
724 let next_link = self
727 .next_cursor
728 .as_ref()
729 .filter(|_| self.has_next)
730 .map(|token| {
731 let encoded: String =
732 url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
733 format!("<?cursor={encoded}&size={}>; rel=\"next\"", self.size)
734 });
735 let mut response = axum::Json(self).into_response();
736 if let Some(link) = next_link
737 && let Ok(value) = HeaderValue::from_str(&link)
738 {
739 response.headers_mut().insert(header::LINK, value);
740 }
741 response
742 }
743}
744
745const BASE64URL_ALPHABET: &[u8; 64] =
755 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
756
757fn base64url_encode(input: &[u8]) -> String {
758 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
759 let mut chunks = input.chunks_exact(3);
760 for chunk in &mut chunks {
761 let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
762 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
763 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
764 out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
765 out.push(BASE64URL_ALPHABET[(n & 0x3F) as usize] as char);
766 }
767 let rem = chunks.remainder();
768 match rem.len() {
769 0 => {}
770 1 => {
771 let n = u32::from(rem[0]) << 16;
772 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
773 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
774 }
775 2 => {
776 let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
777 out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
778 out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
779 out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
780 }
781 _ => unreachable!("chunks_exact remainder is < 3 by construction"),
782 }
783 out
784}
785
786fn base64url_decode(input: &str) -> Option<Vec<u8>> {
787 let bytes = input.as_bytes();
788 let value = |b: u8| -> Option<u32> {
789 match b {
790 b'A'..=b'Z' => Some(u32::from(b - b'A')),
791 b'a'..=b'z' => Some(u32::from(b - b'a') + 26),
792 b'0'..=b'9' => Some(u32::from(b - b'0') + 52),
793 b'-' => Some(62),
794 b'_' => Some(63),
795 _ => None,
796 }
797 };
798 let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
799 let mut i = 0;
800 while i + 4 <= bytes.len() {
801 let n = (value(bytes[i])? << 18)
802 | (value(bytes[i + 1])? << 12)
803 | (value(bytes[i + 2])? << 6)
804 | value(bytes[i + 3])?;
805 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
806 out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
807 out.push(u8::try_from(n & 0xFF).ok()?);
808 i += 4;
809 }
810 match bytes.len() - i {
811 0 => {}
812 1 => return None, 2 => {
814 let n = (value(bytes[i])? << 18) | (value(bytes[i + 1])? << 12);
815 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
816 }
817 3 => {
818 let n = (value(bytes[i])? << 18)
819 | (value(bytes[i + 1])? << 12)
820 | (value(bytes[i + 2])? << 6);
821 out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
822 out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
823 }
824 _ => unreachable!("bytes.len() - i is < 4 by the loop condition"),
825 }
826 Some(out)
827}
828
829pub struct Cursor;
850
851impl Cursor {
852 pub fn encode<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
858 Ok(base64url_encode(&serde_json::to_vec(value)?))
859 }
860
861 #[must_use]
869 pub fn decode<T: DeserializeOwned>(token: &str) -> Option<T> {
870 let bytes = base64url_decode(token)?;
871 serde_json::from_slice(&bytes).ok()
872 }
873
874 pub fn encode_signed<T: Serialize>(value: &T, key: &[u8]) -> Result<String, serde_json::Error> {
892 let payload = serde_json::to_vec(value)?;
893 let payload_b64 = base64url_encode(&payload);
894 let mac = hmac_sha256(key, payload_b64.as_bytes());
895 let sig_b64 = base64url_encode(&mac);
896 Ok(format!("{payload_b64}.{sig_b64}"))
897 }
898
899 #[must_use]
909 pub fn decode_signed<T: DeserializeOwned>(token: &str, key: &[u8]) -> Option<T> {
910 let (payload_b64, sig_b64) = token.split_once('.')?;
911 let expected_sig = base64url_decode(sig_b64)?;
912 let actual_sig = hmac_sha256(key, payload_b64.as_bytes());
913 if !constant_time_eq(&expected_sig, &actual_sig) {
914 return None;
915 }
916 let payload = base64url_decode(payload_b64)?;
917 serde_json::from_slice(&payload).ok()
918 }
919}
920
921fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
922 use hmac::{Hmac, Mac};
923 use sha2::Sha256;
924 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
926 mac.update(message);
927 mac.finalize().into_bytes().into()
928}
929
930fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
931 use subtle::ConstantTimeEq;
932 a.ct_eq(b).into()
933}
934
935#[derive(Debug, Clone, Default, Deserialize)]
959pub struct CursorRequest {
960 #[serde(default)]
961 cursor: Option<String>,
962 #[serde(default)]
963 size: Option<u32>,
964}
965
966impl CursorRequest {
967 #[must_use]
969 pub const fn new(cursor: Option<String>, size: u32) -> Self {
970 Self {
971 cursor,
972 size: Some(size),
973 }
974 }
975
976 #[must_use]
978 pub fn cursor(&self) -> Option<&str> {
979 self.cursor.as_deref()
980 }
981
982 #[must_use]
987 pub fn decode<T: DeserializeOwned>(&self) -> Option<T> {
988 Cursor::decode(self.cursor.as_deref()?)
989 }
990
991 #[must_use]
998 pub fn decode_signed<T: DeserializeOwned>(&self, key: &[u8]) -> Option<T> {
999 Cursor::decode_signed(self.cursor.as_deref()?, key)
1000 }
1001
1002 #[must_use]
1004 pub const fn size(&self) -> u32 {
1005 match self.size {
1006 Some(0) | None => DEFAULT_PAGE_SIZE,
1007 Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
1008 Some(s) => s,
1009 }
1010 }
1011
1012 #[must_use]
1014 pub const fn limit(&self) -> i64 {
1015 self.size() as i64
1016 }
1017
1018 #[must_use]
1024 pub const fn fetch_limit(&self) -> i64 {
1025 self.size() as i64 + 1
1026 }
1027}
1028
1029impl<S> FromRequestParts<S> for CursorRequest
1030where
1031 S: Send + Sync,
1032{
1033 type Rejection = std::convert::Infallible;
1034
1035 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
1036 Ok(parts
1037 .uri
1038 .query()
1039 .map_or_else(Self::default, parse_cursor_query))
1040 }
1041}
1042
1043fn parse_cursor_query(query: &str) -> CursorRequest {
1048 let mut req = CursorRequest::default();
1049 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1050 match key.as_ref() {
1051 "cursor" if !value.is_empty() => {
1052 req.cursor = Some(value.into_owned());
1053 }
1054 "size" => {
1055 if let Ok(n) = value.parse::<u32>() {
1056 req.size = Some(n);
1057 }
1058 }
1059 _ => {}
1060 }
1061 }
1062 req
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct CursorPage<T> {
1088 pub content: Vec<T>,
1090 pub size: u32,
1092 pub next_cursor: Option<String>,
1094 pub has_next: bool,
1096}
1097
1098impl<T> CursorPage<T> {
1099 #[must_use]
1119 pub fn from_overfetched<K, F>(items: Vec<T>, request: &CursorRequest, cursor_fn: F) -> Self
1120 where
1121 K: Serialize,
1122 F: FnOnce(&T) -> K,
1123 {
1124 Self::from_overfetched_inner(items, request, cursor_fn, |k| Cursor::encode(&k).ok())
1125 }
1126
1127 #[must_use]
1139 pub fn from_overfetched_signed<K, F>(
1140 items: Vec<T>,
1141 request: &CursorRequest,
1142 key: &[u8],
1143 cursor_fn: F,
1144 ) -> Self
1145 where
1146 K: Serialize,
1147 F: FnOnce(&T) -> K,
1148 {
1149 Self::from_overfetched_inner(items, request, cursor_fn, |k| {
1150 Cursor::encode_signed(&k, key).ok()
1151 })
1152 }
1153
1154 fn from_overfetched_inner<K, F, E>(
1155 mut items: Vec<T>,
1156 request: &CursorRequest,
1157 cursor_fn: F,
1158 encode: E,
1159 ) -> Self
1160 where
1161 K: Serialize,
1162 F: FnOnce(&T) -> K,
1163 E: FnOnce(K) -> Option<String>,
1164 {
1165 let size = request.size();
1166 let limit = size as usize;
1167 let has_next = items.len() > limit;
1168 if has_next {
1169 items.truncate(limit);
1170 }
1171 let next_cursor = if has_next {
1172 items.last().map(cursor_fn).and_then(encode)
1177 } else {
1178 None
1179 };
1180 let has_next = has_next && next_cursor.is_some();
1183 Self {
1184 content: items,
1185 size,
1186 next_cursor,
1187 has_next,
1188 }
1189 }
1190
1191 #[must_use]
1195 pub const fn empty(request: &CursorRequest) -> Self {
1196 Self {
1197 content: Vec::new(),
1198 size: request.size(),
1199 next_cursor: None,
1200 has_next: false,
1201 }
1202 }
1203
1204 pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPage<U> {
1206 CursorPage {
1207 content: self.content.into_iter().map(f).collect(),
1208 size: self.size,
1209 next_cursor: self.next_cursor,
1210 has_next: self.has_next,
1211 }
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218 use axum::Router;
1219 use axum::body::Body;
1220 use axum::http::{Request, StatusCode};
1221 use axum::routing::get;
1222 use tower::ServiceExt;
1223
1224 #[test]
1227 fn defaults_when_nothing_provided() {
1228 let r = PageRequest::default();
1229 assert_eq!(r.page(), 1);
1230 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1231 assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1232 assert_eq!(r.offset(), 0);
1233 }
1234
1235 #[test]
1236 fn page_zero_is_coerced_to_one() {
1237 let r = PageRequest::new(0, 10);
1238 assert_eq!(r.page(), 1);
1239 assert_eq!(r.offset(), 0);
1240 }
1241
1242 #[test]
1243 fn size_is_clamped_to_max() {
1244 let r = PageRequest::new(1, 9_999);
1245 assert_eq!(r.size(), MAX_PAGE_SIZE);
1246 assert_eq!(r.limit(), i64::from(MAX_PAGE_SIZE));
1247
1248 let exact = PageRequest::new(1, MAX_PAGE_SIZE);
1249 assert_eq!(exact.size(), MAX_PAGE_SIZE);
1250
1251 let over = PageRequest::new(1, MAX_PAGE_SIZE + 1);
1252 assert_eq!(over.size(), MAX_PAGE_SIZE);
1253
1254 let under = PageRequest::new(1, MAX_PAGE_SIZE - 1);
1255 assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1256 }
1257
1258 #[test]
1259 fn size_zero_falls_back_to_default() {
1260 let r = PageRequest::new(3, 0);
1261 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1262 }
1263
1264 #[test]
1265 fn offset_matches_page_and_size() {
1266 let r = PageRequest::new(3, 25);
1267 assert_eq!(r.offset(), 50);
1268 assert_eq!(r.limit(), 25);
1269 }
1270
1271 #[test]
1274 fn empty_page_has_one_total_page() {
1275 let req = PageRequest::new(5, 50);
1276 let page: Page<i32> = Page::empty(&req);
1277 assert_eq!(page.page, 5);
1278 assert_eq!(page.size, 50);
1279 assert_eq!(page.total_elements, 0);
1280 assert_eq!(page.total_pages, 1);
1281 assert!(!page.has_next);
1282 assert!(page.has_previous);
1283 assert!(page.content.is_empty());
1284 }
1285
1286 #[test]
1287 fn metadata_reflects_middle_page() {
1288 let req = PageRequest::new(3, 20);
1289 let page = Page::new(vec![1_i32; 20], 137, &req);
1290 assert_eq!(page.page, 3);
1291 assert_eq!(page.size, 20);
1292 assert_eq!(page.total_elements, 137);
1293 assert_eq!(page.total_pages, 7); assert!(page.has_next);
1295 assert!(page.has_previous);
1296 }
1297
1298 #[test]
1299 fn metadata_reflects_last_page() {
1300 let req = PageRequest::new(7, 20);
1301 let page = Page::new(vec![1_i32; 17], 137, &req);
1302 assert_eq!(page.total_pages, 7);
1303 assert!(!page.has_next);
1304 assert!(page.has_previous);
1305 }
1306
1307 #[test]
1308 fn negative_total_is_treated_as_zero() {
1309 let page: Page<i32> = Page::new(vec![], -1, &PageRequest::default());
1310 assert_eq!(page.total_elements, 0);
1311 assert_eq!(page.total_pages, 1);
1312 }
1313
1314 #[test]
1315 fn map_preserves_metadata() {
1316 let req = PageRequest::new(2, 10);
1317 let page = Page::new(vec![1_i32, 2, 3], 25, &req);
1318 let mapped = page.map(|n| n.to_string());
1319 assert_eq!(mapped.page, 2);
1320 assert_eq!(mapped.size, 10);
1321 assert_eq!(mapped.total_elements, 25);
1322 assert_eq!(mapped.total_pages, 3);
1323 assert!(mapped.has_next);
1324 assert!(mapped.has_previous);
1325 assert_eq!(mapped.content, vec!["1", "2", "3"]);
1326 }
1327
1328 #[test]
1331 fn page_serializes_to_expected_shape() {
1332 let req = PageRequest::new(2, 10);
1333 let page = Page::new(vec!["a", "b"], 25, &req);
1334 let json = serde_json::to_value(&page).unwrap();
1335 assert_eq!(json["page"], 2);
1336 assert_eq!(json["size"], 10);
1337 assert_eq!(json["total_elements"], 25);
1338 assert_eq!(json["total_pages"], 3);
1339 assert_eq!(json["has_next"], true);
1340 assert_eq!(json["has_previous"], true);
1341 assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1342 }
1343
1344 async fn echo(page: PageRequest) -> String {
1347 format!("{}:{}:{}", page.page(), page.size(), page.offset())
1348 }
1349
1350 async fn fetch(uri: &str) -> (StatusCode, String) {
1351 let app = Router::new().route("/items", get(echo));
1352 let res = app
1353 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1354 .await
1355 .unwrap();
1356 let status = res.status();
1357 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1358 .await
1359 .unwrap();
1360 (status, String::from_utf8(bytes.to_vec()).unwrap())
1361 }
1362
1363 #[tokio::test]
1364 async fn extractor_uses_defaults_when_query_missing() {
1365 let (status, body) = fetch("/items").await;
1366 assert_eq!(status, StatusCode::OK);
1367 assert_eq!(body, format!("1:{DEFAULT_PAGE_SIZE}:0"));
1368 }
1369
1370 #[tokio::test]
1371 async fn extractor_parses_page_and_size() {
1372 let (status, body) = fetch("/items?page=4&size=25").await;
1373 assert_eq!(status, StatusCode::OK);
1374 assert_eq!(body, "4:25:75");
1375 }
1376
1377 #[tokio::test]
1378 async fn extractor_clamps_size_over_max() {
1379 let (status, body) = fetch("/items?page=1&size=5000").await;
1380 assert_eq!(status, StatusCode::OK);
1381 assert_eq!(body, format!("1:{MAX_PAGE_SIZE}:0"));
1382 }
1383
1384 #[tokio::test]
1385 async fn extractor_coerces_page_zero_to_one() {
1386 let (status, body) = fetch("/items?page=0&size=10").await;
1387 assert_eq!(status, StatusCode::OK);
1388 assert_eq!(body, "1:10:0");
1389 }
1390
1391 #[tokio::test]
1398 async fn extractor_ignores_non_numeric_page() {
1399 let (status, body) = fetch("/items?page=abc&size=10").await;
1400 assert_eq!(status, StatusCode::OK);
1401 assert_eq!(body, "1:10:0");
1402 }
1403
1404 #[tokio::test]
1405 async fn extractor_ignores_empty_size() {
1406 let (status, body) = fetch("/items?page=2&size=").await;
1407 assert_eq!(status, StatusCode::OK);
1408 assert_eq!(body, format!("2:{DEFAULT_PAGE_SIZE}:{DEFAULT_PAGE_SIZE}"));
1409 }
1410
1411 #[tokio::test]
1412 async fn extractor_uses_last_value_on_duplicate_keys() {
1413 let (status, body) = fetch("/items?page=1&page=4&size=5").await;
1414 assert_eq!(status, StatusCode::OK);
1415 assert_eq!(body, "4:5:15");
1416 }
1417
1418 #[tokio::test]
1419 async fn extractor_ignores_unknown_keys() {
1420 let (status, body) = fetch("/items?sort=name&page=2&size=10").await;
1421 assert_eq!(status, StatusCode::OK);
1422 assert_eq!(body, "2:10:10");
1423 }
1424
1425 #[tokio::test]
1426 async fn extractor_handles_percent_encoded_values() {
1427 let (status, body) = fetch("/items?page=%32&size=10").await;
1429 assert_eq!(status, StatusCode::OK);
1430 assert_eq!(body, "2:10:10");
1431 }
1432
1433 #[tokio::test]
1434 async fn extractor_handles_negative_page_value() {
1435 let (status, body) = fetch("/items?page=-1&size=10").await;
1437 assert_eq!(status, StatusCode::OK);
1438 assert_eq!(body, "1:10:0");
1439 }
1440
1441 #[test]
1444 fn sort_dir_from_param_defaults_to_asc() {
1445 assert_eq!(SortDir::from_param("desc"), SortDir::Desc);
1446 assert_eq!(SortDir::from_param("DESC"), SortDir::Desc);
1447 assert_eq!(SortDir::from_param("asc"), SortDir::Asc);
1448 assert_eq!(SortDir::from_param(""), SortDir::Asc);
1450 assert_eq!(SortDir::from_param("descending"), SortDir::Asc);
1451 assert_eq!(SortDir::from_param("id;DROP TABLE"), SortDir::Asc);
1452 }
1453
1454 #[test]
1455 fn list_query_default_is_empty() {
1456 let q = ListQuery::default();
1457 assert!(q.sort().is_none());
1458 assert_eq!(q.direction(), SortDir::Asc);
1459 assert_eq!(q.filters().count(), 0);
1460 assert!(q.is_empty());
1461 }
1462
1463 #[test]
1464 fn list_query_parses_sort_dir_and_filters() {
1465 let q = parse_list_query("sort=title&dir=desc&filter[published]=true&filter[author]=ada");
1466 assert_eq!(q.sort(), Some("title"));
1467 assert_eq!(q.direction(), SortDir::Desc);
1468 let filters: Vec<(&str, &str)> = q.filters().collect();
1469 assert_eq!(filters, vec![("published", "true"), ("author", "ada")]);
1470 assert!(!q.is_empty());
1471 }
1472
1473 #[test]
1474 fn list_query_ignores_pagination_and_unknown_keys() {
1475 let q = parse_list_query("page=3&size=10&whatever=x&sort=name");
1477 assert_eq!(q.sort(), Some("name"));
1478 assert_eq!(q.filters().count(), 0);
1479 }
1480
1481 #[test]
1482 fn list_query_empty_sort_and_bad_dir_fall_back() {
1483 let q = parse_list_query("sort=&dir=sideways");
1485 assert!(q.sort().is_none());
1486 assert_eq!(q.direction(), SortDir::Asc);
1487 }
1488
1489 #[test]
1490 fn list_query_drops_empty_filter_column() {
1491 let q = parse_list_query("filter[]=orphan&filter[ok]=1");
1492 let filters: Vec<(&str, &str)> = q.filters().collect();
1493 assert_eq!(filters, vec![("ok", "1")]);
1494 }
1495
1496 #[test]
1497 fn list_query_last_sort_wins() {
1498 let q = parse_list_query("sort=a&sort=b");
1499 assert_eq!(q.sort(), Some("b"));
1500 }
1501
1502 #[test]
1503 fn list_query_carries_injection_payload_verbatim() {
1504 let q = parse_list_query("sort=id;DROP%20TABLE%20users");
1508 assert_eq!(q.sort(), Some("id;DROP TABLE users"));
1509 }
1510
1511 async fn list_echo(list: ListQuery) -> String {
1512 let filters: Vec<String> = list.filters().map(|(k, v)| format!("{k}={v}")).collect();
1513 format!(
1514 "{}:{}:{}",
1515 list.sort().unwrap_or("<none>"),
1516 list.direction().param_value(),
1517 filters.join(",")
1518 )
1519 }
1520
1521 async fn fetch_list(uri: &str) -> (StatusCode, String) {
1522 let app = Router::new().route("/items", get(list_echo));
1523 let res = app
1524 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1525 .await
1526 .unwrap();
1527 let status = res.status();
1528 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1529 .await
1530 .unwrap();
1531 (status, String::from_utf8(bytes.to_vec()).unwrap())
1532 }
1533
1534 #[tokio::test]
1535 async fn list_query_extractor_never_400s_on_garbage() {
1536 let (status, body) = fetch_list("/items?sort=id;DROP+TABLE&dir=%00&filter[x]=1").await;
1538 assert_eq!(status, StatusCode::OK);
1539 assert_eq!(body, "id;DROP TABLE:asc:x=1");
1540 }
1541
1542 #[tokio::test]
1543 async fn list_query_extractor_defaults_when_query_missing() {
1544 let (status, body) = fetch_list("/items").await;
1545 assert_eq!(status, StatusCode::OK);
1546 assert_eq!(body, "<none>:asc:");
1547 }
1548
1549 #[test]
1552 fn base64url_encode_known_vectors() {
1553 assert_eq!(base64url_encode(b""), "");
1555 assert_eq!(base64url_encode(b"f"), "Zg");
1556 assert_eq!(base64url_encode(b"fo"), "Zm8");
1557 assert_eq!(base64url_encode(b"foo"), "Zm9v");
1558 assert_eq!(base64url_encode(b"foob"), "Zm9vYg");
1559 assert_eq!(base64url_encode(b"fooba"), "Zm9vYmE");
1560 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1561 }
1562
1563 #[test]
1564 fn base64url_uses_url_safe_alphabet() {
1565 let encoded = base64url_encode(&[0xFB, 0xEF, 0xFF]);
1568 assert!(!encoded.contains('+'));
1569 assert!(!encoded.contains('/'));
1570 assert!(encoded.contains('-') || encoded.contains('_'));
1571 }
1572
1573 #[test]
1574 fn base64url_round_trip_arbitrary_bytes() {
1575 for len in 0_u8..=32 {
1576 let input: Vec<u8> = (0..len).map(|i| i.wrapping_mul(37)).collect();
1577 let encoded = base64url_encode(&input);
1578 let decoded = base64url_decode(&encoded).unwrap();
1579 assert_eq!(decoded, input, "round-trip failed at len {len}");
1580 }
1581 }
1582
1583 #[test]
1584 fn base64url_decode_rejects_invalid_chars() {
1585 assert_eq!(base64url_decode("!!!!"), None);
1586 assert_eq!(base64url_decode("AAAA="), None); assert_eq!(base64url_decode("AAA+"), None); }
1589
1590 #[test]
1591 fn base64url_decode_rejects_one_trailing_char() {
1592 assert_eq!(base64url_decode("A"), None);
1594 assert_eq!(base64url_decode("ZmA"), Some(vec![0x66, 0x60]));
1595 }
1596
1597 #[derive(Serialize, Deserialize, PartialEq, Debug)]
1600 struct PostKey {
1601 created_at: String,
1602 id: i64,
1603 }
1604
1605 #[test]
1606 fn cursor_round_trip_preserves_payload() {
1607 let key = PostKey {
1608 created_at: "2026-04-27T12:00:00Z".to_string(),
1609 id: 12_345,
1610 };
1611 let token = Cursor::encode(&key).unwrap();
1612 let decoded: PostKey = Cursor::decode(&token).unwrap();
1613 assert_eq!(decoded, key);
1614 }
1615
1616 #[test]
1617 fn cursor_token_is_url_safe() {
1618 let key = PostKey {
1621 created_at: "2026-04-27T12:00:00Z".to_string(),
1622 id: 1,
1623 };
1624 let token = Cursor::encode(&key).unwrap();
1625 assert!(
1627 token
1628 .bytes()
1629 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1630 );
1631 }
1632
1633 #[test]
1634 fn cursor_decode_returns_none_for_garbage() {
1635 let decoded: Option<PostKey> = Cursor::decode("!!!not a token!!!");
1637 assert!(decoded.is_none());
1638 }
1639
1640 #[test]
1641 fn cursor_decode_returns_none_for_wrong_schema() {
1642 let other = serde_json::json!({"unrelated": "value"});
1644 let token = Cursor::encode(&other).unwrap();
1645 let decoded: Option<PostKey> = Cursor::decode(&token);
1646 assert!(decoded.is_none());
1647 }
1648
1649 #[test]
1652 fn cursor_request_defaults_when_empty() {
1653 let r = CursorRequest::default();
1654 assert!(r.cursor().is_none());
1655 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1656 assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1657 assert_eq!(r.fetch_limit(), i64::from(DEFAULT_PAGE_SIZE) + 1);
1658 }
1659
1660 #[test]
1661 fn cursor_request_clamps_size_to_max() {
1662 let r = CursorRequest::new(None, 9_999);
1663 assert_eq!(r.size(), MAX_PAGE_SIZE);
1664 assert_eq!(r.fetch_limit(), i64::from(MAX_PAGE_SIZE) + 1);
1665
1666 let exact = CursorRequest::new(None, MAX_PAGE_SIZE);
1667 assert_eq!(exact.size(), MAX_PAGE_SIZE);
1668
1669 let over = CursorRequest::new(None, MAX_PAGE_SIZE + 1);
1670 assert_eq!(over.size(), MAX_PAGE_SIZE);
1671
1672 let under = CursorRequest::new(None, MAX_PAGE_SIZE - 1);
1673 assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1674 }
1675
1676 #[test]
1677 fn cursor_request_zero_size_falls_back_to_default() {
1678 let r = CursorRequest::new(None, 0);
1679 assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1680 }
1681
1682 #[test]
1683 fn cursor_request_decode_helper_returns_none_when_missing() {
1684 let r = CursorRequest::default();
1685 let decoded: Option<PostKey> = r.decode();
1686 assert!(decoded.is_none());
1687 }
1688
1689 #[test]
1690 fn cursor_request_decode_helper_round_trips() {
1691 let key = PostKey {
1692 created_at: "2026-04-27T00:00:00Z".to_string(),
1693 id: 7,
1694 };
1695 let token = Cursor::encode(&key).unwrap();
1696 let r = CursorRequest::new(Some(token), 10);
1697 let decoded: PostKey = r.decode().unwrap();
1698 assert_eq!(decoded, key);
1699 }
1700
1701 #[test]
1704 fn cursor_page_signals_no_next_when_under_limit() {
1705 let req = CursorRequest::new(None, 5);
1706 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1708 assert_eq!(page.content, vec![1, 2, 3]);
1709 assert!(!page.has_next);
1710 assert!(page.next_cursor.is_none());
1711 assert_eq!(page.size, 5);
1712 }
1713
1714 #[test]
1715 fn cursor_page_signals_no_next_at_exact_limit() {
1716 let req = CursorRequest::new(None, 3);
1717 let items = vec![1_i32, 2, 3];
1719 let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1720 assert_eq!(page.content.len(), 3);
1721 assert!(!page.has_next);
1722 assert!(page.next_cursor.is_none());
1723 }
1724
1725 #[test]
1726 fn cursor_page_truncates_overflow_and_emits_next_cursor() {
1727 let req = CursorRequest::new(None, 3);
1728 let items = vec![1_i32, 2, 3, 4];
1730 let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1731 assert_eq!(page.content, vec![1, 2, 3]);
1733 assert!(page.has_next);
1734 let token = page.next_cursor.as_ref().expect("next cursor present");
1735 let decoded: serde_json::Value = Cursor::decode(token).unwrap();
1740 assert_eq!(decoded, serde_json::json!({"id": 3}));
1741 }
1742
1743 #[test]
1744 fn cursor_page_from_overfetched_handles_encoding_failure() {
1745 let req = CursorRequest::new(None, 2);
1746 let items = vec![1_i32, 2, 3];
1747 let page = CursorPage::from_overfetched_inner(
1748 items,
1749 &req,
1750 |&n| n,
1751 |_| None::<String>, );
1753
1754 assert_eq!(page.content, vec![1, 2]);
1755 assert_eq!(page.size, 2);
1756 assert!(page.next_cursor.is_none());
1757 assert!(!page.has_next);
1758 }
1759
1760 #[test]
1761 fn cursor_page_empty_helper() {
1762 let req = CursorRequest::new(None, 10);
1763 let page: CursorPage<i32> = CursorPage::empty(&req);
1764 assert!(page.content.is_empty());
1765 assert_eq!(page.size, 10);
1766 assert!(!page.has_next);
1767 assert!(page.next_cursor.is_none());
1768
1769 let req_diff_size = CursorRequest::new(None, 5);
1770 let page_diff_size: CursorPage<i32> = CursorPage::empty(&req_diff_size);
1771 assert_eq!(page_diff_size.size, 5);
1772 }
1773
1774 #[test]
1775 fn cursor_page_map_preserves_metadata() {
1776 let req = CursorRequest::new(None, 2);
1777 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1779
1780 let original_cursor = page.next_cursor.clone();
1781
1782 let mapped = page.map(|n| n.to_string());
1783 assert_eq!(mapped.content, vec!["1", "2"]);
1784 assert!(mapped.has_next);
1785 assert_eq!(mapped.next_cursor, original_cursor);
1786 assert_eq!(mapped.size, 2);
1787 }
1788
1789 #[test]
1790 fn cursor_page_serializes_to_expected_shape() {
1791 let req = CursorRequest::new(None, 2);
1792 let items = vec!["a", "b", "c"];
1793 let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1794 let json = serde_json::to_value(&page).unwrap();
1795 assert_eq!(json["size"], 2);
1796 assert_eq!(json["has_next"], true);
1797 assert!(json["next_cursor"].is_string());
1798 assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1799 }
1800
1801 #[test]
1802 fn cursor_page_last_page_serializes_null_cursor() {
1803 let req = CursorRequest::new(None, 5);
1804 let items = vec!["only"];
1805 let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1806 let json = serde_json::to_value(&page).unwrap();
1807 assert_eq!(json["has_next"], false);
1808 assert!(json["next_cursor"].is_null());
1809 }
1810
1811 async fn cursor_echo(req: CursorRequest) -> String {
1814 format!(
1815 "{}|{}|{}",
1816 req.cursor().unwrap_or("-"),
1817 req.size(),
1818 req.fetch_limit(),
1819 )
1820 }
1821
1822 async fn fetch_cursor(uri: &str) -> (StatusCode, String) {
1823 let app = Router::new().route("/feed", get(cursor_echo));
1824 let res = app
1825 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1826 .await
1827 .unwrap();
1828 let status = res.status();
1829 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1830 .await
1831 .unwrap();
1832 (status, String::from_utf8(bytes.to_vec()).unwrap())
1833 }
1834
1835 #[tokio::test]
1836 async fn cursor_extractor_uses_defaults_when_query_missing() {
1837 let (status, body) = fetch_cursor("/feed").await;
1838 assert_eq!(status, StatusCode::OK);
1839 assert_eq!(
1840 body,
1841 format!("-|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1842 );
1843 }
1844
1845 #[tokio::test]
1846 async fn cursor_extractor_parses_cursor_and_size() {
1847 let (status, body) = fetch_cursor("/feed?cursor=abc123&size=5").await;
1848 assert_eq!(status, StatusCode::OK);
1849 assert_eq!(body, "abc123|5|6");
1850 }
1851
1852 #[tokio::test]
1853 async fn cursor_extractor_clamps_size_over_max() {
1854 let (status, body) = fetch_cursor("/feed?cursor=t&size=9999").await;
1855 assert_eq!(status, StatusCode::OK);
1856 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1857
1858 let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={MAX_PAGE_SIZE}")).await;
1860 assert_eq!(status, StatusCode::OK);
1861 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1862
1863 let size = MAX_PAGE_SIZE + 1;
1865 let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={size}")).await;
1866 assert_eq!(status, StatusCode::OK);
1867 assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1868 }
1869
1870 #[tokio::test]
1871 async fn cursor_extractor_ignores_empty_cursor() {
1872 let (status, body) = fetch_cursor("/feed?cursor=&size=10").await;
1874 assert_eq!(status, StatusCode::OK);
1875 assert_eq!(body, "-|10|11");
1876 }
1877
1878 #[tokio::test]
1879 async fn cursor_extractor_does_not_400_on_malformed_size() {
1880 let (status, body) = fetch_cursor("/feed?cursor=t&size=abc").await;
1881 assert_eq!(status, StatusCode::OK);
1882 assert_eq!(
1883 body,
1884 format!("t|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1885 );
1886 }
1887
1888 #[tokio::test]
1889 async fn cursor_extractor_handles_percent_encoded_token() {
1890 let (status, body) = fetch_cursor("/feed?cursor=ab%2Dcd&size=2").await;
1893 assert_eq!(status, StatusCode::OK);
1894 assert_eq!(body, "ab-cd|2|3");
1895 }
1896
1897 #[test]
1905 fn concurrent_inserts_do_not_cause_duplicates() {
1906 #[derive(Clone, Debug, PartialEq, Eq)]
1909 struct Row {
1910 id: i64,
1911 created_at: i64,
1912 }
1913 #[derive(Serialize, Deserialize)]
1914 struct Key {
1915 created_at: i64,
1916 id: i64,
1917 }
1918
1919 let mut table: Vec<Row> = (1..=5)
1920 .map(|id| Row {
1921 id,
1922 created_at: 1_000 - id, })
1924 .collect();
1925 table.sort_by_key(|r| std::cmp::Reverse((r.created_at, r.id)));
1927
1928 let req1 = CursorRequest::new(None, 2);
1930 let fetch1 = usize::try_from(req1.fetch_limit()).unwrap();
1931 let fetched1: Vec<Row> = table.iter().take(fetch1).cloned().collect();
1932 let page1 = CursorPage::from_overfetched(fetched1, &req1, |r| Key {
1933 created_at: r.created_at,
1934 id: r.id,
1935 });
1936 let cursor1 = page1.next_cursor.clone().expect("page 1 has next");
1937 assert_eq!(page1.content.len(), 2);
1938
1939 table.insert(
1944 0,
1945 Row {
1946 id: 99,
1947 created_at: 9_999,
1948 },
1949 );
1950
1951 let req2 = CursorRequest::new(Some(cursor1), 2);
1953 let key: Key = req2.decode().unwrap();
1954 let fetch2 = usize::try_from(req2.fetch_limit()).unwrap();
1955
1956 let fetched2: Vec<Row> = table
1960 .iter()
1961 .filter(|r| {
1962 r.created_at < key.created_at || (r.created_at == key.created_at && r.id < key.id)
1963 })
1964 .take(fetch2)
1965 .cloned()
1966 .collect();
1967 let page2 = CursorPage::from_overfetched(fetched2, &req2, |r| Key {
1968 created_at: r.created_at,
1969 id: r.id,
1970 });
1971
1972 let mut all: Vec<Row> = page1.content;
1976 all.extend(page2.content);
1977 let mut ids: Vec<i64> = all.iter().map(|r| r.id).collect();
1978 let original_len = ids.len();
1979 ids.sort_unstable();
1980 ids.dedup();
1981 assert_eq!(ids.len(), original_len, "no duplicates across pages");
1982 assert!(
1983 !all.iter().any(|r| r.id == 99),
1984 "concurrently-inserted row not duplicated"
1985 );
1986 }
1987
1988 const TEST_KEY: &[u8] = b"test-signing-key-do-not-use-in-prod";
1991
1992 #[derive(Serialize, Deserialize, PartialEq, Debug)]
1993 struct ScopedCursor {
1994 tenant_id: i64,
1995 cursor_id: i64,
1996 }
1997
1998 #[test]
1999 fn signed_cursor_round_trip() {
2000 let payload = ScopedCursor {
2001 tenant_id: 42,
2002 cursor_id: 7,
2003 };
2004 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2005 assert!(token.contains('.'));
2007 let decoded: ScopedCursor = Cursor::decode_signed(&token, TEST_KEY).unwrap();
2008 assert_eq!(decoded, payload);
2009 }
2010
2011 #[test]
2012 fn signed_cursor_rejects_tampered_payload() {
2013 let payload = ScopedCursor {
2014 tenant_id: 42,
2015 cursor_id: 7,
2016 };
2017 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2018 let forged_payload = ScopedCursor {
2021 tenant_id: 99,
2022 cursor_id: 7,
2023 };
2024 let forged_b64 = base64url_encode(&serde_json::to_vec(&forged_payload).unwrap());
2025 let (_, sig_b64) = token.split_once('.').unwrap();
2026 let forged_token = format!("{forged_b64}.{sig_b64}");
2027 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&forged_token, TEST_KEY);
2028 assert!(decoded.is_none(), "tampered cursor must not verify");
2029 }
2030
2031 #[test]
2032 fn signed_cursor_rejects_wrong_key() {
2033 let payload = ScopedCursor {
2034 tenant_id: 42,
2035 cursor_id: 7,
2036 };
2037 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2038 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&token, b"different-key");
2039 assert!(decoded.is_none());
2040 }
2041
2042 #[test]
2043 fn signed_cursor_rejects_unsigned_token() {
2044 let payload = ScopedCursor {
2047 tenant_id: 42,
2048 cursor_id: 7,
2049 };
2050 let unsigned = Cursor::encode(&payload).unwrap();
2051 let decoded: Option<ScopedCursor> = Cursor::decode_signed(&unsigned, TEST_KEY);
2052 assert!(
2053 decoded.is_none(),
2054 "unsigned token must not pass signed verification"
2055 );
2056 }
2057
2058 #[test]
2059 fn signed_cursor_rejects_missing_signature_segment() {
2060 let decoded: Option<ScopedCursor> = Cursor::decode_signed("just-some-bytes", TEST_KEY);
2062 assert!(decoded.is_none());
2063 }
2064
2065 #[test]
2066 fn signed_cursor_rejects_garbage() {
2067 let decoded: Option<ScopedCursor> = Cursor::decode_signed("!!!.!!!", TEST_KEY);
2068 assert!(decoded.is_none());
2069 }
2070
2071 #[test]
2072 fn cursor_request_decode_signed_returns_none_when_missing() {
2073 let r = CursorRequest::default();
2074 let decoded: Option<ScopedCursor> = r.decode_signed(TEST_KEY);
2075 assert!(decoded.is_none());
2076 }
2077
2078 #[test]
2079 fn cursor_request_decode_signed_round_trips() {
2080 let payload = ScopedCursor {
2081 tenant_id: 42,
2082 cursor_id: 7,
2083 };
2084 let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2085 let r = CursorRequest::new(Some(token), 10);
2086 let decoded: ScopedCursor = r.decode_signed(TEST_KEY).unwrap();
2087 assert_eq!(decoded, payload);
2088 }
2089
2090 #[test]
2091 fn cursor_page_from_overfetched_signed_emits_signed_token() {
2092 let req = CursorRequest::new(None, 2);
2093 let items = vec![1_i32, 2, 3]; let page = CursorPage::from_overfetched_signed(items, &req, TEST_KEY, |&n| ScopedCursor {
2095 tenant_id: 42,
2096 cursor_id: i64::from(n),
2097 });
2098 assert!(page.has_next);
2099 let token = page.next_cursor.as_ref().unwrap();
2100 assert!(token.contains('.'), "signed token format is payload.sig");
2101 let key: ScopedCursor = Cursor::decode_signed(token, TEST_KEY).unwrap();
2103 assert_eq!(key.cursor_id, 2); let mishandled: Option<ScopedCursor> = Cursor::decode(token);
2107 assert!(mishandled.is_none());
2108 }
2109
2110 #[test]
2111 fn signed_cursor_signature_is_constant_time_compared() {
2112 let p = ScopedCursor {
2116 tenant_id: 1,
2117 cursor_id: 1,
2118 };
2119 let a = Cursor::encode_signed(&p, b"k1").unwrap();
2120 let b = Cursor::encode_signed(&p, b"k1").unwrap();
2121 let c = Cursor::encode_signed(&p, b"k2").unwrap();
2122 assert_eq!(a, b);
2123 assert_ne!(a, c);
2124 }
2125
2126 #[tokio::test]
2127 async fn page_request_extractor_defaults_on_missing_uri_query() {
2128 use axum::extract::FromRequestParts;
2129 use axum::http::Request;
2130 let req = Request::builder().uri("/").body(()).unwrap();
2131 let (mut parts, ()) = req.into_parts();
2132
2133 let extracted = PageRequest::from_request_parts(&mut parts, &())
2134 .await
2135 .unwrap();
2136
2137 assert_eq!(extracted.page(), 1);
2138 assert_eq!(extracted.size(), 20); }
2140
2141 #[tokio::test]
2142 async fn cursor_extractor_defaults_on_missing_uri_query() {
2143 use axum::extract::FromRequestParts;
2144 use axum::http::Request;
2145 let req = Request::builder().uri("/").body(()).unwrap();
2146 let (mut parts, ()) = req.into_parts();
2147
2148 let extracted = CursorRequest::from_request_parts(&mut parts, &())
2149 .await
2150 .unwrap();
2151
2152 assert_eq!(extracted.size(), 20); assert!(extracted.cursor.is_none());
2154 }
2155
2156 #[test]
2157 fn base64url_encode_pad_branch() {
2158 let encoded = base64url_encode(b"ab");
2160 assert_eq!(encoded, "YWI");
2161 }
2162
2163 #[test]
2164 fn base64url_decode_pad_branch() {
2165 let decoded = base64url_decode("YWI").unwrap();
2168 assert_eq!(decoded, b"ab");
2169 }
2170
2171 #[test]
2172 fn cursor_encode_fails_gracefully_on_serialization_error() {
2173 use serde::Serialize;
2174
2175 struct FailToSerialize;
2176
2177 impl Serialize for FailToSerialize {
2178 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2179 where
2180 S: serde::Serializer,
2181 {
2182 Err(serde::ser::Error::custom("forced failure"))
2183 }
2184 }
2185
2186 let res = Cursor::encode(&FailToSerialize);
2187 assert!(res.is_err());
2188
2189 let res_signed = Cursor::encode_signed(&FailToSerialize, b"key");
2190 assert!(res_signed.is_err());
2191 }
2192
2193 #[tokio::test]
2196 async fn page_into_response_emits_envelope_and_link_headers() {
2197 async fn handler() -> Page<u32> {
2198 Page::new((21..=40).collect(), 137, &PageRequest::new(2, 20))
2200 }
2201 let app = Router::new().route("/items", get(handler));
2202 let res = app
2203 .oneshot(
2204 Request::builder()
2205 .uri("/items")
2206 .body(Body::empty())
2207 .unwrap(),
2208 )
2209 .await
2210 .unwrap();
2211
2212 assert_eq!(res.status(), StatusCode::OK);
2213 let link = res
2214 .headers()
2215 .get(header::LINK)
2216 .expect("Link header must be present")
2217 .to_str()
2218 .unwrap()
2219 .to_owned();
2220 assert!(link.contains("<?page=1&size=20>; rel=\"first\""), "{link}");
2221 assert!(
2222 link.contains("<?page=1&size=20>; rel=\"prev\""),
2223 "prev must point to page 1: {link}"
2224 );
2225 assert!(link.contains("<?page=3&size=20>; rel=\"next\""), "{link}");
2226 assert!(link.contains("<?page=7&size=20>; rel=\"last\""), "{link}");
2227
2228 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2229 .await
2230 .unwrap();
2231 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2232 assert_eq!(json["page"], 2);
2233 assert_eq!(json["size"], 20);
2234 assert_eq!(json["total_elements"], 137);
2235 assert_eq!(json["total_pages"], 7);
2236 assert_eq!(json["content"].as_array().unwrap().len(), 20);
2237 }
2238
2239 #[tokio::test]
2240 async fn first_page_has_no_prev_link() {
2241 async fn handler() -> Page<u32> {
2242 Page::new((1..=20).collect(), 40, &PageRequest::new(1, 20))
2243 }
2244 let app = Router::new().route("/items", get(handler));
2245 let res = app
2246 .oneshot(
2247 Request::builder()
2248 .uri("/items")
2249 .body(Body::empty())
2250 .unwrap(),
2251 )
2252 .await
2253 .unwrap();
2254 let link = res
2255 .headers()
2256 .get(header::LINK)
2257 .unwrap()
2258 .to_str()
2259 .unwrap()
2260 .to_owned();
2261 assert!(
2262 !link.contains("rel=\"prev\""),
2263 "page 1 must not advertise prev: {link}"
2264 );
2265 assert!(link.contains("rel=\"next\""), "{link}");
2266 assert!(link.contains("rel=\"first\""), "{link}");
2267 assert!(link.contains("rel=\"last\""), "{link}");
2268 }
2269
2270 #[test]
2271 fn empty_page_last_link_clamps_to_one() {
2272 let link = page_link_header_value(1, 20, 0, false);
2275 assert!(
2276 link.contains("<?page=1&size=20>; rel=\"last\""),
2277 "last link must clamp to page 1 for an empty collection: {link}"
2278 );
2279 assert!(
2280 !link.contains("page=0"),
2281 "no link may reference the invalid page 0: {link}"
2282 );
2283 }
2284
2285 #[tokio::test]
2286 async fn cursor_page_emits_next_link_only() {
2287 async fn handler() -> CursorPage<u32> {
2288 CursorPage::from_overfetched(vec![1, 2, 3], &CursorRequest::new(None, 2), |&n| n)
2290 }
2291 let app = Router::new().route("/feed", get(handler));
2292 let res = app
2293 .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2294 .await
2295 .unwrap();
2296 let link = res
2297 .headers()
2298 .get(header::LINK)
2299 .expect("cursor page with a next page must emit a Link")
2300 .to_str()
2301 .unwrap()
2302 .to_owned();
2303 assert!(link.contains("rel=\"next\""), "{link}");
2304 assert!(
2305 link.contains("cursor="),
2306 "next link must carry the cursor token: {link}"
2307 );
2308 assert!(
2309 !link.contains("rel=\"prev\""),
2310 "keyset pagination has no prev: {link}"
2311 );
2312 }
2313
2314 #[tokio::test]
2315 async fn cursor_page_last_page_has_no_link() {
2316 async fn handler() -> CursorPage<u32> {
2317 CursorPage::from_overfetched(vec![1, 2], &CursorRequest::new(None, 5), |&n| n)
2319 }
2320 let app = Router::new().route("/feed", get(handler));
2321 let res = app
2322 .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2323 .await
2324 .unwrap();
2325 assert!(
2326 res.headers().get(header::LINK).is_none(),
2327 "last cursor page must not advertise a next link"
2328 );
2329 }
2330
2331 #[test]
2332 fn paginate_in_memory_windows_and_counts() {
2333 let all: Vec<u32> = (1..=25).collect();
2334 let page = Page::paginate_in_memory(all, &PageRequest::new(2, 10));
2335 assert_eq!(page.page, 2);
2336 assert_eq!(page.size, 10);
2337 assert_eq!(page.total_elements, 25);
2338 assert_eq!(page.total_pages, 3);
2339 assert_eq!(page.content, (11..=20).collect::<Vec<_>>());
2340 assert!(page.has_next);
2341 assert!(page.has_previous);
2342 }
2343}