Skip to content

Commit 88a3aee

Browse files
graylikemeclaude
andcommitted
test: add regression tests for pagination bugs
Unit tests for cursor encode/decode roundtrip and invalid input. Integration tests (sqlx::test with temp DB + migrations) for both units and equipment pagination: - keyset_pagination_no_duplicates_or_gaps: inserts rows in reverse-alpha order so IDs don't match name sort, paginates through all pages, asserts correct order with no duplicates - total_count_stable_across_pages: verifies totalCount is identical on page 1 and page 2 - pagination_with_duplicate_names: verifies the ID tiebreaker works when multiple rows share the same sort value Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0497133 commit 88a3aee

4 files changed

Lines changed: 284 additions & 0 deletions

File tree

crates/api/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ serde_json = "1"
2929
base64 = "0.22"
3030
chrono = { version = "0.4", features = ["serde"] }
3131
anyhow = "1"
32+
33+
[dev-dependencies]
34+
sqlx = { version = "0.8", features = ["migrate"] }

crates/api/src/db/equipment.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,90 @@ pub async fn search(
124124

125125
Ok((rows, total_count, has_next))
126126
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use super::*;
131+
132+
/// Insert equipment in reverse-alpha order so IDs don't match name sort.
133+
async fn seed_equipment(pool: &PgPool) {
134+
let items = [
135+
("zzz-laser", "ZZZ Laser"),
136+
("death-ray", "Death Ray"),
137+
("medium-laser", "Medium Laser"),
138+
("beam-rifle", "Beam Rifle"),
139+
("alpha-cannon", "Alpha Cannon"),
140+
];
141+
for (slug, name) in &items {
142+
sqlx::query(
143+
"INSERT INTO equipment (slug, name, category, tech_base, rules_level)
144+
VALUES ($1, $2, 'energy_weapon', 'inner_sphere', 'standard')",
145+
)
146+
.bind(slug)
147+
.bind(name)
148+
.execute(pool)
149+
.await
150+
.unwrap();
151+
}
152+
}
153+
154+
fn empty_filter() -> EquipmentFilter<'static> {
155+
EquipmentFilter {
156+
name_search: None,
157+
category: None,
158+
tech_base: None,
159+
rules_level: None,
160+
max_tonnage: None,
161+
max_crits: None,
162+
observed_location: None,
163+
ammo_for_slug: None,
164+
}
165+
}
166+
167+
/// Regression: same keyset pagination bug as units — IDs vs name order.
168+
#[sqlx::test(migrations = "../../migrations")]
169+
async fn keyset_pagination_no_duplicates_or_gaps(pool: PgPool) {
170+
seed_equipment(&pool).await;
171+
172+
let mut all_names: Vec<String> = vec![];
173+
let mut cursor: Option<(String, i32)> = None;
174+
175+
loop {
176+
let cursor_ref = cursor.as_ref().map(|(s, id)| (s.as_str(), *id));
177+
let (rows, total, has_next) =
178+
search(&pool, empty_filter(), 2, cursor_ref).await.unwrap();
179+
180+
assert_eq!(total, 5, "totalCount must be stable across all pages");
181+
182+
for row in &rows {
183+
all_names.push(row.name.clone());
184+
}
185+
186+
if !has_next {
187+
break;
188+
}
189+
let last = rows.last().unwrap();
190+
cursor = Some((last.name.clone(), last.id));
191+
}
192+
193+
assert_eq!(
194+
all_names,
195+
["Alpha Cannon", "Beam Rifle", "Death Ray", "Medium Laser", "ZZZ Laser"],
196+
"items must appear in alphabetical order with no duplicates or gaps"
197+
);
198+
}
199+
200+
/// Regression: totalCount must not shrink on page 2.
201+
#[sqlx::test(migrations = "../../migrations")]
202+
async fn total_count_stable_across_pages(pool: PgPool) {
203+
seed_equipment(&pool).await;
204+
205+
let (page1, total1, _) = search(&pool, empty_filter(), 2, None).await.unwrap();
206+
let last = page1.last().unwrap();
207+
let cursor = (last.name.as_str(), last.id);
208+
209+
let (_, total2, _) = search(&pool, empty_filter(), 2, Some(cursor)).await.unwrap();
210+
211+
assert_eq!(total1, total2, "totalCount must not change between pages");
212+
}
213+
}

crates/api/src/db/units.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,168 @@ pub async fn get_quirks(pool: &PgPool, unit_id: i32) -> Result<Vec<DbQuirk>, App
290290
.await?;
291291
Ok(rows)
292292
}
293+
294+
#[cfg(test)]
295+
mod tests {
296+
use super::*;
297+
298+
/// Insert units whose auto-increment IDs do NOT match alphabetical name
299+
/// order — the exact scenario that triggered the keyset pagination bug.
300+
async fn seed_units(pool: &PgPool) {
301+
sqlx::query(
302+
"INSERT INTO unit_chassis (slug, name, unit_type, tech_base, tonnage)
303+
VALUES ('test-mech', 'Test', 'BattleMech', 'inner_sphere', 75)",
304+
)
305+
.execute(pool)
306+
.await
307+
.unwrap();
308+
309+
let chassis_id: i32 =
310+
sqlx::query_scalar("SELECT id FROM unit_chassis WHERE slug = 'test-mech'")
311+
.fetch_one(pool)
312+
.await
313+
.unwrap();
314+
315+
// Inserted in reverse-alpha order → id 1=Zephyr … id 5=Atlas.
316+
let units = [
317+
("zephyr-zph-1", "ZPH-1", "Zephyr ZPH-1"),
318+
("dragon-drg-1n", "DRG-1N", "Dragon DRG-1N"),
319+
("centurion-cn9-a", "CN9-A", "Centurion CN9-A"),
320+
("banshee-bnc-3e", "BNC-3E", "Banshee BNC-3E"),
321+
("atlas-as7-d", "AS7-D", "Atlas AS7-D"),
322+
];
323+
for (slug, variant, full_name) in &units {
324+
sqlx::query(
325+
"INSERT INTO units (slug, chassis_id, variant, full_name, tech_base, rules_level, tonnage)
326+
VALUES ($1, $2, $3, $4, 'inner_sphere', 'standard', 75)",
327+
)
328+
.bind(slug)
329+
.bind(chassis_id)
330+
.bind(variant)
331+
.bind(full_name)
332+
.execute(pool)
333+
.await
334+
.unwrap();
335+
}
336+
}
337+
338+
fn empty_filter() -> UnitFilter<'static> {
339+
UnitFilter {
340+
name_search: None,
341+
tech_base: None,
342+
rules_level: None,
343+
tonnage_min: None,
344+
tonnage_max: None,
345+
faction_slug: None,
346+
era_slug: None,
347+
is_omnimech: None,
348+
config: None,
349+
engine_type: None,
350+
has_jump: None,
351+
role: None,
352+
}
353+
}
354+
355+
/// Regression: pagination must not produce duplicates or skip items when
356+
/// DB row IDs don't match the alphabetical sort order.
357+
#[sqlx::test(migrations = "../../migrations")]
358+
async fn keyset_pagination_no_duplicates_or_gaps(pool: PgPool) {
359+
seed_units(&pool).await;
360+
361+
let mut all_names: Vec<String> = vec![];
362+
let mut cursor: Option<(String, i32)> = None;
363+
let mut pages = 0;
364+
365+
loop {
366+
let cursor_ref = cursor.as_ref().map(|(s, id)| (s.as_str(), *id));
367+
let (rows, total, has_next) =
368+
search(&pool, empty_filter(), 2, cursor_ref).await.unwrap();
369+
370+
assert_eq!(total, 5, "totalCount must be stable across all pages");
371+
372+
for row in &rows {
373+
all_names.push(row.full_name.clone());
374+
}
375+
376+
pages += 1;
377+
if !has_next {
378+
break;
379+
}
380+
let last = rows.last().unwrap();
381+
cursor = Some((last.full_name.clone(), last.id));
382+
}
383+
384+
assert_eq!(pages, 3, "5 items / page size 2 = 3 pages");
385+
assert_eq!(
386+
all_names,
387+
["Atlas AS7-D", "Banshee BNC-3E", "Centurion CN9-A", "Dragon DRG-1N", "Zephyr ZPH-1"],
388+
"items must appear in alphabetical order with no duplicates or gaps"
389+
);
390+
}
391+
392+
/// Regression: totalCount must reflect all filtered rows, not just rows
393+
/// remaining after the cursor position.
394+
#[sqlx::test(migrations = "../../migrations")]
395+
async fn total_count_stable_across_pages(pool: PgPool) {
396+
seed_units(&pool).await;
397+
398+
let (page1, total1, _) = search(&pool, empty_filter(), 2, None).await.unwrap();
399+
let last = page1.last().unwrap();
400+
let cursor = (last.full_name.as_str(), last.id);
401+
402+
let (_, total2, _) = search(&pool, empty_filter(), 2, Some(cursor)).await.unwrap();
403+
404+
assert_eq!(total1, total2, "totalCount must not change between pages");
405+
}
406+
407+
/// Edge case: items with the same name must paginate correctly using the
408+
/// ID tiebreaker.
409+
#[sqlx::test(migrations = "../../migrations")]
410+
async fn pagination_with_duplicate_names(pool: PgPool) {
411+
sqlx::query(
412+
"INSERT INTO unit_chassis (slug, name, unit_type, tech_base, tonnage)
413+
VALUES ('test-mech', 'Test', 'BattleMech', 'inner_sphere', 75)",
414+
)
415+
.execute(&pool)
416+
.await
417+
.unwrap();
418+
419+
let chassis_id: i32 =
420+
sqlx::query_scalar("SELECT id FROM unit_chassis WHERE slug = 'test-mech'")
421+
.fetch_one(&pool)
422+
.await
423+
.unwrap();
424+
425+
for i in 1..=3 {
426+
sqlx::query(
427+
"INSERT INTO units (slug, chassis_id, variant, full_name, tech_base, rules_level, tonnage)
428+
VALUES ($1, $2, $3, 'Same Name', 'inner_sphere', 'standard', 75)",
429+
)
430+
.bind(format!("same-name-{i}"))
431+
.bind(chassis_id)
432+
.bind(format!("V{i}"))
433+
.execute(&pool)
434+
.await
435+
.unwrap();
436+
}
437+
438+
let (page1, _, has_next) = search(&pool, empty_filter(), 2, None).await.unwrap();
439+
assert!(has_next);
440+
assert_eq!(page1.len(), 2);
441+
442+
let last = page1.last().unwrap();
443+
let cursor = (last.full_name.as_str(), last.id);
444+
let (page2, _, has_next2) = search(&pool, empty_filter(), 2, Some(cursor)).await.unwrap();
445+
assert!(!has_next2);
446+
assert_eq!(page2.len(), 1);
447+
448+
let all_ids: Vec<i32> = page1.iter().chain(page2.iter()).map(|u| u.id).collect();
449+
assert_eq!(all_ids.len(), 3);
450+
let unique: std::collections::HashSet<i32> = all_ids.iter().copied().collect();
451+
assert_eq!(unique.len(), 3, "no duplicate IDs");
452+
assert!(
453+
all_ids.windows(2).all(|w| w[0] < w[1]),
454+
"IDs must be ascending when names are equal"
455+
);
456+
}
457+
}

crates/api/src/graphql/pagination.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,32 @@ pub struct PageInfo {
3030
/// Total number of items matching the query filters, across all pages.
3131
pub total_count: i64,
3232
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
38+
#[test]
39+
fn encode_decode_roundtrip() {
40+
let cursor = encode_cursor("Atlas AS7-D", 42);
41+
let (sort_val, id) = decode_cursor(&cursor).unwrap();
42+
assert_eq!(sort_val, "Atlas AS7-D");
43+
assert_eq!(id, 42);
44+
}
45+
46+
#[test]
47+
fn encode_decode_special_characters() {
48+
let cursor = encode_cursor("'Mech (Special) / Variant", 999);
49+
let (sort_val, id) = decode_cursor(&cursor).unwrap();
50+
assert_eq!(sort_val, "'Mech (Special) / Variant");
51+
assert_eq!(id, 999);
52+
}
53+
54+
#[test]
55+
fn decode_invalid_returns_none() {
56+
assert!(decode_cursor("not-valid-base64!!!").is_none());
57+
assert!(decode_cursor(&STANDARD.encode("missing-id-marker")).is_none());
58+
assert!(decode_cursor(&STANDARD.encode("value|id:not_a_number")).is_none());
59+
assert!(decode_cursor("").is_none());
60+
}
61+
}

0 commit comments

Comments
 (0)