feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:21:04 +02:00
parent 5efa7b8a16
commit 60a1b8dccf
4 changed files with 388 additions and 29 deletions
+136
View File
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
assert_eq!(all[1].object_number, "LM-2");
}
fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
ObjectInput {
object_number: number.into(),
object_name: name.into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility,
}
}
async fn seed(pool: &PgPool, inputs: &[ObjectInput]) {
let db = Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
for it in inputs {
catalog::create_object(&mut tx, AuditActor::System, it)
.await
.unwrap();
}
tx.commit().await.unwrap();
}
#[sqlx::test]
async fn query_orders_by_name_descending(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("LM-1", "alpha", Visibility::Draft),
input("LM-2", "gamma", Visibility::Draft),
input("LM-3", "beta", Visibility::Draft),
],
)
.await;
let query = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectName,
descending: true,
visibility: None,
q: None,
};
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
.await
.unwrap();
let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect();
assert_eq!(names, ["gamma", "beta", "alpha"]);
}
#[sqlx::test]
async fn query_filters_by_visibility(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("LM-1", "draft one", Visibility::Draft),
input("LM-2", "internal one", Visibility::Internal),
input("LM-3", "draft two", Visibility::Draft),
],
)
.await;
let query = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: Some("draft"),
q: None,
};
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|o| o.visibility == Visibility::Draft));
let total = catalog::count_objects_query(db.pool(), Some("draft"), None)
.await
.unwrap();
assert_eq!(total, 2);
}
#[sqlx::test]
async fn query_quick_filter_matches_number_or_name(pool: PgPool) {
let db = Db::from_pool(pool.clone());
seed(
&pool,
&[
input("RED-1", "scarlet vase", Visibility::Draft),
input("BLU-1", "azure bowl", Visibility::Draft),
input("LM-9", "red kettle", Visibility::Internal),
],
)
.await;
// Matches the object_number of the first row.
let by_number = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: None,
q: Some("red"),
};
let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0)
.await
.unwrap();
// ILIKE: "RED-1" by number and "red kettle" by name.
assert_eq!(rows.len(), 2);
let total = catalog::count_objects_query(db.pool(), None, Some("red"))
.await
.unwrap();
assert_eq!(total, 2);
// A term matching only a name.
let by_name = catalog::ObjectQuery {
sort: catalog::ObjectSort::ObjectNumber,
descending: false,
visibility: None,
q: Some("azure"),
};
let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].object_number, "BLU-1");
}
#[sqlx::test]
async fn object_by_id_missing_is_none(pool: PgPool) {
let db = Db::from_pool(pool);