diff --git a/server/test/edit_inbox_list_test.gleam b/server/test/edit_inbox_list_test.gleam index 1a44eae..d78d7ba 100644 --- a/server/test/edit_inbox_list_test.gleam +++ b/server/test/edit_inbox_list_test.gleam @@ -1,9 +1,6 @@ //// End-to-end `catalog.listEditProposals` handler tests for cursor -//// pagination: backward compat with no params, the exact-boundary and -//// empty-page edge cases, and a stale/garbage cursor restarting from the -//// top. Same `wisp/simulate` + stub PDS client style as `shelf_list_test`; -//// `Deps.backlinks`/`fetch_edit` stand in for Constellation/Slingshot, one -//// own release fans out to several proposals. +//// pagination. Same `wisp/simulate` + stub PDS client style as +//// `shelf_list_test`; one own release fans out to several proposals. import at_record/gen/catalog/edit as catalog_edit import at_record/gen/catalog/release as catalog_release @@ -183,34 +180,23 @@ fn proposal_uris(body: String) -> List(String) { } } -// --- no-params backward compat --- - -pub fn no_params_returns_the_full_list_with_no_cursor_field_test() { - let #(ctx, cfg) = test_context(deps_with_proposals(5)) - let body = list_proposals("", ctx, cfg) - // Sorted newest-first: n=5 first, n=1 last. - assert proposal_uris(body) - == [ - proposal_uri(5), - proposal_uri(4), - proposal_uri(3), - proposal_uri(2), - proposal_uri(1), - ] - assert support.field_present(body, ["cursor"]) == False -} - -// --- exact-boundary page --- - -pub fn a_limit_matching_the_full_list_returns_no_next_cursor_test() { +pub fn a_full_list_fetch_has_no_next_cursor_test() { let #(ctx, cfg) = test_context(deps_with_proposals(5)) - let body = list_proposals("?limit=5", ctx, cfg) - assert list.length(proposal_uris(body)) == 5 - assert support.field_present(body, ["cursor"]) == False + let newest_first = [ + proposal_uri(5), + proposal_uri(4), + proposal_uri(3), + proposal_uri(2), + proposal_uri(1), + ] + ["", "?limit=5"] + |> list.each(fn(query) { + let body = list_proposals(query, ctx, cfg) + assert proposal_uris(body) == newest_first + assert support.field_present(body, ["cursor"]) == False + }) } -// --- empty page --- - pub fn a_cursor_past_the_last_proposal_returns_an_empty_page_test() { let #(ctx, cfg) = test_context(deps_with_proposals(5)) let cursor = pagination.encode_cursor("proposals", proposal_uri(1)) @@ -219,23 +205,16 @@ pub fn a_cursor_past_the_last_proposal_returns_an_empty_page_test() { assert support.field_present(body, ["cursor"]) == False } -// --- stale/garbage cursor restarts from top --- - -pub fn a_garbage_cursor_restarts_from_the_top_test() { - let #(ctx, cfg) = test_context(deps_with_proposals(5)) - let body = list_proposals("?limit=2&cursor=not-a-real-cursor", ctx, cfg) - assert proposal_uris(body) == [proposal_uri(5), proposal_uri(4)] -} - -pub fn a_cursor_naming_an_unknown_proposal_restarts_from_the_top_test() { +pub fn a_stale_or_garbage_cursor_restarts_from_the_top_test() { let #(ctx, cfg) = test_context(deps_with_proposals(5)) let unknown = pagination.encode_cursor("proposals", "at://unknown/x/y") - let body = list_proposals("?limit=2&cursor=" <> unknown, ctx, cfg) - assert proposal_uris(body) == [proposal_uri(5), proposal_uri(4)] + ["not-a-real-cursor", unknown] + |> list.each(fn(cursor) { + let body = list_proposals("?limit=2&cursor=" <> cursor, ctx, cfg) + assert proposal_uris(body) == [proposal_uri(5), proposal_uri(4)] + }) } -// --- normal walk across pages --- - pub fn a_cursor_resumes_after_the_previous_page_test() { let #(ctx, cfg) = test_context(deps_with_proposals(5)) let first = list_proposals("?limit=2", ctx, cfg) diff --git a/server/test/pagination_test.gleam b/server/test/pagination_test.gleam index 4bdd1fc..92d2150 100644 --- a/server/test/pagination_test.gleam +++ b/server/test/pagination_test.gleam @@ -1,9 +1,7 @@ -//// `pagination.page`/`encode_cursor`/`decode_cursor`: the cursor convention -//// shared by shelf.listEntries and catalog.listEditProposals. Uses plain -//// strings as the paged items (id_of = identity) since the slicing logic -//// doesn't care what's being paged. +//// The cursor convention shared by shelf.listEntries and catalog.listEditProposals. import at_record_server/pagination +import gleam/bit_array import gleam/int import gleam/list import gleam/option.{None, Some} @@ -14,150 +12,90 @@ fn ids(count: Int) -> List(String) { |> list.index_map(fn(_, i) { "item-" <> zero_padded(i + 1) }) } -// Zero-padded to a fixed width so string.compare sorts the same as numeric -// order, matching how entry ids (TIDs) sort lexically. +// Zero-padded so string.compare sorts the same as numeric order, matching +// how entry ids (TIDs) sort lexically. fn zero_padded(n: Int) -> String { - let digits = int.to_string(n) - string.pad_start(digits, 3, "0") + string.pad_start(int.to_string(n), 3, "0") } fn identity(s: String) -> String { s } -// --- encode/decode --- - pub fn decode_cursor_roundtrips_an_encoded_cursor_test() { let token = pagination.encode_cursor("owned", "item-005") assert pagination.decode_cursor("owned", token) == Some("item-005") } -pub fn decode_cursor_rejects_a_different_namespace_test() { - let token = pagination.encode_cursor("owned", "item-005") - assert pagination.decode_cursor("wanted", token) == None -} - -pub fn decode_cursor_rejects_unparseable_base64_test() { - assert pagination.decode_cursor("owned", "!!!not base64!!!") == None -} - -pub fn decode_cursor_rejects_a_token_without_the_namespace_separator_test() { - // Valid base64, but decodes to a string with no "namespace:" prefix at all. - let token = pagination.encode_cursor("owned", "no-colon-here") - assert pagination.decode_cursor("wanted", token) == None +pub fn decode_cursor_rejects_malformed_or_mismatched_tokens_test() { + let mismatched_namespace = pagination.encode_cursor("owned", "item-005") + let no_separator = + bit_array.base64_url_encode(bit_array.from_string("no-colon-here"), False) + [ + #("wanted", mismatched_namespace), + #("owned", "!!!not base64!!!"), + #("owned", no_separator), + ] + |> list.each(fn(row) { + let #(namespace, token) = row + assert pagination.decode_cursor(namespace, token) == None + }) } -// --- page: no-params backward compat --- - -pub fn page_with_no_cursor_and_no_limit_returns_everything_test() { +pub fn page_with_no_cursor_and_a_limit_covering_everything_returns_it_all_test() { let items = ids(5) - let #(page, next) = pagination.page(items, identity, "owned", None, None) - assert page == items - assert next == None + [None, Some(5), Some(1000), Some(0)] + |> list.each(fn(limit) { + let #(page, next) = pagination.page(items, identity, "owned", None, limit) + assert page == items + assert next == None + }) } -// --- page: exact-boundary --- - -pub fn page_stops_with_no_cursor_when_the_list_exactly_fills_the_limit_test() { - let items = ids(3) - let #(page, next) = pagination.page(items, identity, "owned", None, Some(3)) - assert page == items - assert next == None -} - -// --- page: empty page --- - pub fn page_past_the_end_of_the_list_is_empty_with_no_next_cursor_test() { let items = ids(3) - let last = pagination.encode_cursor("owned", "item-003") + let cursor = pagination.encode_cursor("owned", "item-003") let #(page, next) = - pagination.page(items, identity, "owned", Some(last), Some(10)) + pagination.page(items, identity, "owned", Some(cursor), Some(10)) assert page == [] assert next == None } -// --- page: normal walk across pages --- - -pub fn page_returns_a_next_cursor_when_more_remain_test() { - let items = ids(5) - let #(page, next) = pagination.page(items, identity, "owned", None, Some(2)) - assert page == ["item-001", "item-002"] - assert next == Some(pagination.encode_cursor("owned", "item-002")) -} - -pub fn page_resumes_after_the_cursors_id_test() { +pub fn page_walks_across_pages_by_cursor_test() { let items = ids(5) - let cursor = pagination.encode_cursor("owned", "item-002") - let #(page, next) = - pagination.page(items, identity, "owned", Some(cursor), Some(2)) - assert page == ["item-003", "item-004"] - assert next == Some(pagination.encode_cursor("owned", "item-004")) -} - -pub fn page_reaching_the_final_item_yields_no_next_cursor_test() { - let items = ids(5) - let cursor = pagination.encode_cursor("owned", "item-004") - let #(page, next) = - pagination.page(items, identity, "owned", Some(cursor), Some(2)) - assert page == ["item-005"] - assert next == None -} - -// --- page: stale/garbage cursor restarts from top --- - -pub fn page_with_an_unrecognized_id_restarts_from_the_top_test() { + [ + #(None, ["item-001", "item-002"], Some("item-002")), + #(Some("item-002"), ["item-003", "item-004"], Some("item-004")), + #(Some("item-004"), ["item-005"], None), + ] + |> list.each(fn(row) { + let #(cursor_id, expected_page, next_id) = row + let cursor = option.map(cursor_id, pagination.encode_cursor("owned", _)) + let #(page, next) = + pagination.page(items, identity, "owned", cursor, Some(2)) + assert page == expected_page + assert next == option.map(next_id, pagination.encode_cursor("owned", _)) + }) +} + +pub fn page_restarts_from_the_top_on_a_stale_or_mismatched_cursor_test() { let items = ids(5) let stale = pagination.encode_cursor("owned", "item-099") - let #(page, _next) = - pagination.page(items, identity, "owned", Some(stale), Some(2)) - assert page == ["item-001", "item-002"] -} - -pub fn page_with_garbage_cursor_text_restarts_from_the_top_test() { - let items = ids(5) - let #(page, _next) = - pagination.page(items, identity, "owned", Some("not-a-cursor"), Some(2)) - assert page == ["item-001", "item-002"] -} - -// --- page: view/namespace interaction --- - -pub fn page_with_a_cursor_from_a_different_namespace_restarts_from_the_top_test() { - let items = ids(5) - let cursor = pagination.encode_cursor("owned", "item-002") - // Switching the shelf's view (namespace) invalidates a cursor minted - // under the previous view, same as an unrecognized cursor. - let #(page, _next) = - pagination.page(items, identity, "wanted", Some(cursor), Some(2)) - assert page == ["item-001", "item-002"] -} - -// --- page: limit clamping --- - -pub fn page_clamps_a_limit_above_the_default_ceiling_test() { - let items = ids(5) - let #(page, next) = - pagination.page(items, identity, "owned", None, Some(1000)) - assert page == items - assert next == None -} - -pub fn page_treats_a_non_positive_limit_as_the_default_test() { - let items = ids(5) - let #(page, _next) = pagination.page(items, identity, "owned", None, Some(0)) - assert page == items + let wrong_namespace = pagination.encode_cursor("owned", "item-002") + [#("owned", stale), #("owned", "not-a-cursor"), #("wanted", wrong_namespace)] + |> list.each(fn(row) { + let #(namespace, cursor) = row + let #(page, _next) = + pagination.page(items, identity, namespace, Some(cursor), Some(2)) + assert page == ["item-001", "item-002"] + }) } pub fn page_with_only_a_cursor_and_no_limit_uses_the_default_limit_test() { let items = ids(pagination.default_limit + 5) - let #(page, next) = pagination.page(items, identity, "owned", None, None) - // Sanity check on the fixture: no cursor/no limit is still full-list. - assert list.length(page) == pagination.default_limit + 5 - assert next == None - let cursor = pagination.encode_cursor("owned", "item-001") - let #(paged, paged_next) = + let #(page, next) = pagination.page(items, identity, "owned", Some(cursor), None) - assert list.length(paged) == pagination.default_limit - assert paged_next != None + assert list.length(page) == pagination.default_limit + assert next != None } diff --git a/server/test/shelf_list_test.gleam b/server/test/shelf_list_test.gleam index 7117480..4c005f0 100644 --- a/server/test/shelf_list_test.gleam +++ b/server/test/shelf_list_test.gleam @@ -1,10 +1,5 @@ -//// End-to-end `shelf.listEntries` handler tests for cursor pagination: -//// backward compat with no params, the exact-boundary and empty-page edge -//// cases, a stale/garbage cursor restarting from the top, and reusing a -//// cursor minted under one view against another (namespace mismatch, -//// restarts the same way). Mirrors `discogs_scan_test`'s `wisp/simulate` + -//// stub PDS client style: no real network, a canned `listRecords` response -//// stands in for the caller's shelf. +//// End-to-end `shelf.listEntries` handler tests for cursor pagination. +//// Mirrors `discogs_scan_test`'s `wisp/simulate` + stub PDS client style. import at_record/gen/defs.{Snapshot} import at_record/gen/shelf/entry.{ @@ -75,7 +70,6 @@ fn stub_entry(action: String, n: Int) -> ShelfEntry { ) } -/// A `shelf.entry` genesis record for entry `n`. fn record_json(n: Int, action: String) -> json.Json { let uri = "at://" <> did <> "/dev.mokkenstorm.crate.shelf.entry/" <> rkey(n) json.object([ @@ -85,8 +79,8 @@ fn record_json(n: Int, action: String) -> json.Json { ]) } -/// listRecords response: entries 1..owned_count are "acquired" (the owned -/// view), the rest up to `total` are "wanted". +/// Entries 1..owned_count are "acquired" (the owned view), the rest up to +/// `total` are "wanted". fn client_with_entries(owned_count: Int, total: Int) -> xrpc.Client { let records = list.repeat(Nil, total) @@ -123,61 +117,41 @@ fn item_ids(body: String) -> List(String) { } } -// --- no-params backward compat --- - -pub fn no_params_returns_the_full_view_with_no_cursor_field_test() { - let #(ctx, cfg) = test_context(client_with_entries(5, 5)) - let body = list_shelf("", ctx, cfg) - assert item_ids(body) == [rkey(1), rkey(2), rkey(3), rkey(4), rkey(5)] - assert support.field_present(body, ["cursor"]) == False -} - -// --- exact-boundary page --- - -pub fn a_limit_matching_the_full_view_returns_no_next_cursor_test() { +pub fn a_full_view_fetch_has_no_next_cursor_test() { let #(ctx, cfg) = test_context(client_with_entries(5, 5)) - let body = list_shelf("?limit=5", ctx, cfg) - assert item_ids(body) == [rkey(1), rkey(2), rkey(3), rkey(4), rkey(5)] - assert support.field_present(body, ["cursor"]) == False + let all = [rkey(1), rkey(2), rkey(3), rkey(4), rkey(5)] + ["", "?limit=5"] + |> list.each(fn(query) { + let body = list_shelf(query, ctx, cfg) + assert item_ids(body) == all + assert support.field_present(body, ["cursor"]) == False + }) } -// --- empty page --- - pub fn a_cursor_past_the_last_entry_returns_an_empty_page_test() { let #(ctx, cfg) = test_context(client_with_entries(5, 5)) - // Resuming after the last owned entry (as if the client had walked one - // page further than the view actually has) yields nothing left to fetch. let cursor = pagination.encode_cursor("owned", rkey(5)) let body = list_shelf("?limit=1&cursor=" <> cursor, ctx, cfg) assert item_ids(body) == [] assert support.field_present(body, ["cursor"]) == False } -// --- stale/garbage cursor restarts from top --- - -pub fn a_garbage_cursor_restarts_from_the_top_test() { - let #(ctx, cfg) = test_context(client_with_entries(5, 5)) - let body = list_shelf("?limit=2&cursor=not-a-real-cursor", ctx, cfg) - assert item_ids(body) == [rkey(1), rkey(2)] -} - -pub fn a_cursor_naming_an_unknown_id_restarts_from_the_top_test() { +pub fn a_stale_or_garbage_cursor_restarts_from_the_top_test() { let #(ctx, cfg) = test_context(client_with_entries(5, 5)) - let unknown = pagination.encode_cursor("owned", "g999") - let body = list_shelf("?limit=2&cursor=" <> unknown, ctx, cfg) - assert item_ids(body) == [rkey(1), rkey(2)] + let unknown_id = pagination.encode_cursor("owned", "g999") + ["not-a-real-cursor", unknown_id] + |> list.each(fn(cursor) { + let body = list_shelf("?limit=2&cursor=" <> cursor, ctx, cfg) + assert item_ids(body) == [rkey(1), rkey(2)] + }) } -// --- view + cursor interaction --- - +// A cursor minted under one view must not resume mid-list when replayed +// against another view; it restarts from that view's top instead. pub fn a_cursor_from_a_different_view_restarts_within_the_new_view_test() { - // 3 owned, 2 wanted. let #(ctx, cfg) = test_context(client_with_entries(3, 5)) let owned_first_page = list_shelf("?limit=2", ctx, cfg) let assert Ok(cursor) = support.field_string(owned_first_page, ["cursor"]) - - // Reusing the owned-view cursor against the wanted view must not resume - // mid-owned-list; it restarts from the top of the wanted view instead. let wanted_body = list_shelf("?view=wanted&limit=10&cursor=" <> cursor, ctx, cfg) assert item_ids(wanted_body) == [rkey(4), rkey(5)] diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 7f5f7ab..90b4c0f 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -99,26 +99,21 @@ pub fn set_view_resets_to_loading_test() { assert updated.crate_window == model.crate_window_size } -// A cursor is scoped to the view it was minted under, so switching filters -// must drop it (and any fetch already in flight) rather than resume into -// the wrong view's pages. -pub fn set_view_resets_the_cursor_and_loading_state_test() { +// A cursor is scoped to the view it was minted under, so both reloads drop +// it (and any fetch in flight) rather than resume into the wrong pages. +pub fn set_view_and_retry_shelf_reset_the_cursor_and_loading_state_test() { let seeded = Model( ..logged_in(), crate_cursor: Some("owned:e1"), shelf_loading_more: True, ) - let #(updated, _) = update(seeded, SetView("wanted")) - assert updated.crate_cursor == None - assert updated.shelf_loading_more == False -} - -pub fn retry_shelf_resets_the_cursor_test() { - let seeded = - Model(..logged_in(), shelf: ShelfFailed, crate_cursor: Some("owned:e1")) - let #(updated, _) = update(seeded, RetryShelf) - assert updated.crate_cursor == None + [update(seeded, SetView("wanted")), update(seeded, RetryShelf)] + |> list.each(fn(result) { + let #(updated, _) = result + assert updated.crate_cursor == None + assert updated.shelf_loading_more == False + }) } pub fn set_display_switches_to_rows_test() { @@ -278,48 +273,26 @@ pub fn got_shelf_stores_the_next_cursor_test() { // --- server-paginated LOAD MORE (fetch beyond the local window) --- -pub fn show_more_crate_widens_the_window_for_free_when_enough_is_already_fetched_test() { - // 60 fetched, window at 30: the next 30 are already in memory, so LOAD - // MORE just reveals them locally even though a cursor remains. - let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(60)), - crate_window: 30, - crate_cursor: Some("owned:e59"), - ) - let #(updated, effect) = update(seeded, ShowMoreCrate) - assert updated.crate_window == 60 - assert updated.shelf_loading_more == False - assert effect == empty_effect() -} - -pub fn show_more_crate_fetches_the_next_page_once_the_window_catches_up_test() { - // Window already covers everything fetched: LOAD MORE has to reach for - // the next page over the wire. - let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(30)), - crate_window: 30, - crate_cursor: Some("owned:e29"), - ) - let #(updated, effect) = update(seeded, ShowMoreCrate) - assert updated.shelf_loading_more == True - assert effect != empty_effect() -} - -pub fn show_more_crate_is_a_no_op_fetch_once_the_cursor_is_exhausted_test() { - let seeded = - Model( - ..logged_in(), - shelf: ShelfLoaded(many_entries(30)), - crate_window: 30, - crate_cursor: None, - ) - let #(updated, effect) = update(seeded, ShowMoreCrate) - assert updated.shelf_loading_more == False - assert effect == empty_effect() +pub fn show_more_crate_only_fetches_once_the_window_catches_up_to_the_cursor_test() { + [ + #(60, Some("owned:e59"), False), + #(30, Some("owned:e29"), True), + #(30, None, False), + ] + |> list.each(fn(row) { + let #(fetched, cursor, expect_fetch) = row + let seeded = + Model( + ..logged_in(), + shelf: ShelfLoaded(many_entries(fetched)), + crate_window: 30, + crate_cursor: cursor, + ) + let #(updated, effect) = update(seeded, ShowMoreCrate) + assert updated.crate_window == 30 + model.crate_window_size + assert updated.shelf_loading_more == expect_fetch + assert { effect == empty_effect() } != expect_fetch + }) } pub fn got_shelf_more_appends_and_advances_the_cursor_test() { @@ -351,8 +324,7 @@ pub fn got_shelf_more_dedupes_by_entry_id_test() { shelf: ShelfLoaded(many_entries(2)), crate_cursor: Some("owned:e1"), ) - // The "next" page overlaps with e1 (as if a write shifted the fold - // between pages): the overlap must not be duplicated. + // A write shifting the fold between pages must not duplicate the overlap. let overlapping_page = ShelfData("alice.test", many_entries(3) |> list.drop(1), dict.new(), None) let #(updated, _) = update(seeded, GotShelfMore(Ok(overlapping_page)))