diff --git a/server/src/at_record_server/handlers/shelf.gleam b/server/src/at_record_server/handlers/shelf.gleam index e2d15d4..e845b11 100644 --- a/server/src/at_record_server/handlers/shelf.gleam +++ b/server/src/at_record_server/handlers/shelf.gleam @@ -22,6 +22,7 @@ import at_record_server/event_log import at_record_server/handle_resolver import at_record_server/handlers/cover_proxy import at_record_server/oauth/sessions.{type OauthSession} +import at_record_server/pagination import at_record_server/promotion import at_record_server/provenance.{now_rfc3339} import atproto/blob @@ -34,6 +35,7 @@ import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string import wisp.{type Request, type Response} // --- reads --- @@ -44,26 +46,45 @@ pub fn list_shelf(req: Request, ctx: Context) -> Response { case event_log.load(client, session) { Error(_) -> error_json(502, "could not load crate from PDS") Ok(events) -> { - let view = - list.key_find(wisp.get_query(req), "view") |> result.unwrap("owned") + let query = wisp.get_query(req) + let view = list.key_find(query, "view") |> result.unwrap("owned") // Snapshot-less entries (legacy/purge remnants) would fail the frontend decoder, so they never leave the BFF. + // The fold needs every event to reduce correctly, so pagination slices + // the already-folded, filtered, deterministically-ordered view rather + // than the raw log. let entries = crate.fold(events) |> list.filter(in_view(view, _)) |> list.filter(fn(e) { e.snapshot != None }) - |> list.map(resolve_cover(session, _)) - json.object([ - #("handle", json.string(session.handle)), - #("items", json.array(entries, crate.encode_entry)), - #( - "viaHandles", - json.object( - via_handles(ctx, session, entries) - |> dict.to_list - |> list.map(fn(pair) { #(pair.0, json.string(pair.1)) }), - ), - ), - ]) + |> list.sort(fn(a, b) { string.compare(a.entry_id, b.entry_id) }) + let cursor = list.key_find(query, "cursor") |> option.from_result + let limit = + list.key_find(query, "limit") + |> result.try(int.parse) + |> option.from_result + let #(page, next_cursor) = + pagination.page(entries, fn(e) { e.entry_id }, view, cursor, limit) + let resolved = list.map(page, resolve_cover(session, _)) + json.object( + list.flatten([ + [ + #("handle", json.string(session.handle)), + #("items", json.array(resolved, crate.encode_entry)), + #( + "viaHandles", + json.object( + via_handles(ctx, session, resolved) + |> dict.to_list + |> list.map(fn(pair) { #(pair.0, json.string(pair.1)) }), + ), + ), + ], + case next_cursor { + Some(c) -> [#("cursor", json.string(c))] + None -> [] + }, + ]), + ) |> json.to_string |> wisp.json_response(200) } diff --git a/server/test/shelf_list_test.gleam b/server/test/shelf_list_test.gleam new file mode 100644 index 0000000..7117480 --- /dev/null +++ b/server/test/shelf_list_test.gleam @@ -0,0 +1,185 @@ +//// 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. + +import at_record/gen/defs.{Snapshot} +import at_record/gen/shelf/entry.{ + type ShelfEntry, ShelfEntry, encode_shelf_entry, +} +import at_record_server/context.{type Context} +import at_record_server/handlers/shelf as shelf_handler +import at_record_server/oauth/config +import at_record_server/oauth/session_store +import at_record_server/oauth/sessions +import at_record_server/pagination +import atproto/xrpc +import gleam/bit_array +import gleam/http +import gleam/http/response +import gleam/int +import gleam/json +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import support +import wisp +import wisp/simulate + +const session_cookie = "ar_oauth_sid" + +const far_future = 9_999_999_999 + +const did = "did:plc:x" + +fn a_session() -> sessions.OauthSession { + support.stub_session_with( + access_token: "at", + refresh_token: "rt", + expires_at: far_future, + ) +} + +fn test_context(client: xrpc.Client) -> #(Context, config.Config) { + let cfg = support.stub_config_with(client, "r", "http://localhost:8080") + let ctx = + support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + fn(_req) { Error("unused") }, + [], + ) + #(ctx, cfg) +} + +fn rkey(n: Int) -> String { + "g" <> string.pad_start(int.to_string(n), 3, "0") +} + +fn stub_entry(action: String, n: Int) -> ShelfEntry { + let label = rkey(n) + ShelfEntry( + ..support.blank_shelf_entry(), + action:, + snapshot: Some(Snapshot( + artist_display: "Artist " <> label, + cover: None, + format: None, + thumb_url: None, + title: "Title " <> label, + year: None, + )), + ) +} + +/// 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([ + #("uri", json.string(uri)), + #("cid", json.string("bafy" <> rkey(n))), + #("value", encode_shelf_entry(stub_entry(action, n))), + ]) +} + +/// listRecords response: 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) + |> list.index_map(fn(_, i) { + let n = i + 1 + case n <= owned_count { + True -> record_json(n, "acquired") + False -> record_json(n, "wanted") + } + }) + let body = json.object([#("records", json.preprocessed_array(records))]) + xrpc.Client(send: fn(_req) { + Ok(response.Response(200, [], bit_array.from_string(json.to_string(body)))) + }) +} + +fn list_shelf(query: String, ctx: Context, cfg: config.Config) -> String { + let assert Ok(id) = session_store.create(cfg.sessions, a_session()) + let req = + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.listEntries" <> query, + ) + |> simulate.cookie(session_cookie, id, wisp.Signed) + let resp = shelf_handler.list_shelf(req, ctx) + assert resp.status == 200 + simulate.read_body(resp) +} + +fn item_ids(body: String) -> List(String) { + case support.field_nested(body, ["items"], ["entryId"]) { + Ok(ids) -> ids + Error(_) -> [] + } +} + +// --- 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() { + 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 +} + +// --- 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() { + 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)] +} + +// --- view + cursor interaction --- + +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)] + assert support.field_present(wanted_body, ["cursor"]) == False +}