fix(mangas): address review on sort feature — alias, index, validation, a11y
Backend - sort=recent stays a back-compat alias for created (parse_sort); invalid sort/order now return the structured 422 envelope instead of plain-text 400 - per-field default direction on omitted order (dates desc, text asc), matching the frontend so a bare ?sort=<field> reads the same in UI and API - migration 0033: index mangas(updated_at DESC, id) backing the default sort and its id tie-break; NULLS LAST now applied only to the nullable author key - tests: recent alias, per-field default (title+author), invalid-value 422, and a tie-break test that pins ordering by ascending id (mutation-verified) Frontend - pure sort helpers extracted to $lib/mangaSort with unit tests; coerceSort honors the recent alias so pasted/legacy URLs resolve to the same field - SegmentedControl: roving tabindex + arrow/Home/End keyboard nav, anchored on focus so rapid keypresses don't stick - UX: visible "Direction" labels (desktop + mobile), mobile sort-sheet section headings and a "Done" button; README sort/order contract updated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -281,6 +281,178 @@ async fn list_sort_author_orders_by_author_name_nulls_last(pool: PgPool) {
|
||||
assert_eq!(title_list(&body), vec!["Chi", "Akira", "Boku", "NoAuthor"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_sort_recent_alias_maps_to_created_desc(pool: PgPool) {
|
||||
// `recent` was the pre-0.88 sort value (newest created first). It is kept
|
||||
// as an alias for `created` so existing bots/scripts don't break.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
for title in ["Alpha", "Bravo", "Charlie"] {
|
||||
seed(&h.app, &cookie, title).await;
|
||||
}
|
||||
set_created_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await;
|
||||
set_created_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await;
|
||||
set_created_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/mangas?sort=recent"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Same ordering as `sort=created&order=desc`: newest-created first.
|
||||
assert_eq!(title_list(&body), vec!["Bravo", "Charlie", "Alpha"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_sort_field_defaults_direction_per_field(pool: PgPool) {
|
||||
// When `order` is omitted, the API applies the field's natural direction:
|
||||
// text fields ascend (A→Z), matching the frontend's `defaultOrderFor`, so
|
||||
// a bare `?sort=title` link reads the same in the UI and over the wire.
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
for title in ["Vinland Saga", "Berserk", "One Piece"] {
|
||||
seed(&h.app, &cookie, title).await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/mangas?sort=title"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(title_list(&body), vec!["Berserk", "One Piece", "Vinland Saga"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_sort_author_default_is_ascending_nulls_last(pool: PgPool) {
|
||||
// The other text field: a bare `?sort=author` must default to ascending and
|
||||
// keep authorless mangas last (NULLS LAST), exercising the omitted-order
|
||||
// path through the author subquery — not just `title`.
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
for (title, authors) in [
|
||||
("Akira", json!(["Beta Author"])),
|
||||
("Boku", json!(["Alpha Author"])),
|
||||
("NoAuthor", json!([])),
|
||||
] {
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
"/api/v1/mangas",
|
||||
MultipartBuilder::new()
|
||||
.add_json("metadata", json!({ "title": title, "authors": authors })),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/mangas?sort=author"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Alpha Author (Boku) < Beta Author (Akira); authorless sorts last.
|
||||
assert_eq!(title_list(&body), vec!["Boku", "Akira", "NoAuthor"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_rejects_invalid_sort_and_order_with_envelope(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
// Unknown sort field → structured 422, not axum's plain-text 400.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get("/api/v1/mangas?sort=bogus"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["details"]["sort"], "invalid");
|
||||
|
||||
// Unknown order → same treatment.
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/mangas?order=sideways"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["details"]["order"], "invalid");
|
||||
}
|
||||
|
||||
/// Pin a manga's primary key to an explicit UUID. Safe only for freshly
|
||||
/// seeded mangas with no chapters/authors/tags referencing them.
|
||||
async fn set_id(pool: &PgPool, title: &str, id: &str) {
|
||||
let uuid: uuid::Uuid = id.parse().unwrap();
|
||||
sqlx::query("UPDATE mangas SET id = $1 WHERE title = $2")
|
||||
.bind(uuid)
|
||||
.bind(title)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_tie_break_orders_equal_keys_by_ascending_id(pool: PgPool) {
|
||||
// The trailing `, id` in ORDER BY is what keeps pagination stable when the
|
||||
// primary sort key ties. Pin three mangas to the SAME created_at and assign
|
||||
// ids whose ascending order (Bravo < Charlie < Alpha) deliberately differs
|
||||
// from insertion/physical order, so the expected ordering can ONLY come
|
||||
// from the id tie-break.
|
||||
//
|
||||
// We sort by `created` on purpose: `mangas_created_at_idx` is `(created_at
|
||||
// DESC)` with no id, so it does NOT encode the secondary order — unlike the
|
||||
// default `updated` path, whose `mangas_updated_at_idx (updated_at DESC,
|
||||
// id)` would mask a missing clause. Drop `, id` from the query and this
|
||||
// test fails.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
|
||||
for title in ["Alpha", "Bravo", "Charlie"] {
|
||||
seed(&h.app, &cookie, title).await;
|
||||
set_created_at(&pool, title, "2022-01-01T00:00:00Z").await;
|
||||
}
|
||||
set_id(&pool, "Alpha", "33333333-3333-4333-8333-333333333333").await;
|
||||
set_id(&pool, "Bravo", "11111111-1111-4111-8111-111111111111").await;
|
||||
set_id(&pool, "Charlie", "22222222-2222-4222-8222-222222222222").await;
|
||||
|
||||
let page = |limit: i64, offset: i64| {
|
||||
let app = h.app.clone();
|
||||
async move {
|
||||
let resp = app
|
||||
.oneshot(common::get(&format!(
|
||||
"/api/v1/mangas?sort=created&limit={limit}&offset={offset}"
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
common::body_json(resp).await
|
||||
}
|
||||
};
|
||||
|
||||
// Single page: tied rows come back in ascending-id order.
|
||||
let full = page(50, 0).await;
|
||||
assert_eq!(title_list(&full), vec!["Bravo", "Charlie", "Alpha"]);
|
||||
|
||||
// Paginating one row at a time reproduces that exact sequence — no overlap,
|
||||
// no gap — which only holds because the id tie-break makes the order total.
|
||||
let paged: Vec<String> = vec![page(1, 0).await, page(1, 1).await, page(1, 2).await]
|
||||
.iter()
|
||||
.map(|b| b["items"][0]["title"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(paged, vec!["Bravo", "Charlie", "Alpha"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn search_reflects_filtered_total(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
Reference in New Issue
Block a user