diff --git a/server/src/at_record_server/edit_inbox.gleam b/server/src/at_record_server/edit_inbox.gleam index 7741647..7aef22a 100644 --- a/server/src/at_record_server/edit_inbox.gleam +++ b/server/src/at_record_server/edit_inbox.gleam @@ -13,11 +13,12 @@ import at_record/gen/catalog/release as catalog_release import at_record/gen/defs import at_record_server/catalog/source as catalog_source import at_record_server/catalog_deps.{type Deps} -import at_record_server/handle_resolver +import at_record_server/identity_resolver import at_record_server/oauth/sessions.{type OauthSession} import atproto/constellation import atproto/repo import atproto/xrpc.{type Client} +import gleam/dict.{type Dict} import gleam/dynamic/decode import gleam/int import gleam/list @@ -86,17 +87,35 @@ pub fn current_only(rows: List(OwnRelease)) -> List(OwnRelease) { } /// The proposal rows across every capped own-current release, newest first, -/// capped again as a merged set. +/// capped again as a merged set. Proposer handles are resolved in one +/// batched `resolve_many` call across every backlink from every release, +/// rather than once per proposal, since the same proposer commonly appears +/// against several of the caller's releases. pub fn list_proposals( deps: Deps, - client: Client, session: OauthSession, own: List(OwnRelease), variant_source: catalog_source.Source, + identity: identity_resolver.Resolver, ) -> List(ProposalRow) { let adoption_count = subject_adoption_count(variant_source) - cap_releases(own) - |> list.flat_map(proposals_for(deps, client, session, adoption_count, _)) + let per_release = + cap_releases(own) + |> list.map(fn(owned) { #(owned, backlinks_for(deps, session, owned)) }) + let handles = + per_release + |> list.flat_map(fn(pair) { list.map(pair.1, fn(b) { b.did }) }) + |> identity_resolver.resolve_many(identity, _) + |> dict.map_values(fn(_did, identity) { identity.handle }) + per_release + |> list.flat_map(fn(pair) { + let #(owned, backlinks) = pair + backlinks + |> list.filter_map(fn(b) { + to_proposal(deps, handles, adoption_count, owned, b) + |> option.to_result(Nil) + }) + }) // Constellation may return the same linking record more than once. |> unique_by_uri |> list.sort(fn(a, b) { string.compare(b.created_at, a.created_at) }) @@ -158,13 +177,11 @@ fn cap_rows(rows: List(a), cap: Int, what: String) -> List(a) { } } -fn proposals_for( +fn backlinks_for( deps: Deps, - client: Client, session: OauthSession, - adoption_count: fn(String) -> Option(Int), owned: OwnRelease, -) -> List(ProposalRow) { +) -> List(constellation.Backlink) { case deps.backlinks(owned.ref.uri, edit_backlink_source) { Error(e) -> { wisp.log_warning( @@ -180,16 +197,12 @@ fn proposals_for( // Self-authored proposals against your own record shouldn't occur // (amending your own record never files one), but drop them defensively. |> list.filter(fn(b) { b.did != session.did }) - |> list.filter_map(fn(b) { - to_proposal(deps, client, adoption_count, owned, b) - |> option.to_result(Nil) - }) } } fn to_proposal( deps: Deps, - client: Client, + handles: Dict(String, String), adoption_count: fn(String) -> Option(Int), owned: OwnRelease, backlink: constellation.Backlink, @@ -205,7 +218,7 @@ fn to_proposal( uri: edit_uri, cid:, proposer_did: backlink.did, - proposer_handle: handle_resolver.resolve_handle(client, backlink.did), + proposer_handle: dict.get(handles, backlink.did) |> option.from_result, target_uri: target.uri, release_title: owned.value.title, current: Some(current_fields(owned.value)), diff --git a/server/src/at_record_server/handle_resolver.gleam b/server/src/at_record_server/handle_resolver.gleam deleted file mode 100644 index 6a5260b..0000000 --- a/server/src/at_record_server/handle_resolver.gleam +++ /dev/null @@ -1,43 +0,0 @@ -//// Resolve a DID to its current handle via the PLC directory's DID -//// document (`alsoKnownAs`). Best-effort: any failure just means no handle -//// to show, never a hard error — this is cosmetic (an attribution chip), -//// never load-bearing. - -import atproto/xrpc.{type Client} -import gleam/dynamic/decode -import gleam/list -import gleam/option.{type Option, None} -import gleam/result -import gleam/string - -const plc_directory = "https://plc.directory" - -pub fn resolve_handle(client: Client, did: String) -> Option(String) { - let url = plc_directory <> "/" <> did - { - use resp <- result.try( - xrpc.get(client, url, None) |> result.replace_error(Nil), - ) - xrpc.parse(resp.body, doc_decoder()) |> result.replace_error(Nil) - } - |> option.from_result - |> option.then(handle_from_akas) -} - -/// The first `at://` entry in a DID document's `alsoKnownAs`, which is the -/// canonical handle. Split out from `resolve_handle` so the parsing rule can -/// be unit tested without a network call. -pub fn handle_from_akas(akas: List(String)) -> Option(String) { - akas - |> list.find_map(fn(aka) { - case string.starts_with(aka, "at://") { - True -> Ok(string.drop_start(aka, 5)) - False -> Error(Nil) - } - }) - |> option.from_result -} - -fn doc_decoder() -> decode.Decoder(List(String)) { - decode.field("alsoKnownAs", decode.list(decode.string), decode.success) -} diff --git a/server/src/at_record_server/handlers/cover_proxy.gleam b/server/src/at_record_server/handlers/cover_proxy.gleam index 9d8b838..3ed9221 100644 --- a/server/src/at_record_server/handlers/cover_proxy.gleam +++ b/server/src/at_record_server/handlers/cover_proxy.gleam @@ -5,9 +5,9 @@ //// to its PDS once, fetches the blob, and caches the bytes on disk so every //// later load is served locally instead. -import at_record/gen/client as generated_client import at_record_server/config_env import at_record_server/context.{type Context} +import at_record_server/identity_resolver import at_record_server/url_guard.{is_safe_pds_url} import atproto/xrpc import gleam/bytes_tree @@ -57,21 +57,14 @@ fn fetch_and_cache( dir: String, base: String, ) -> Response { - case - generated_client.identity_resolve_mini_doc( - ctx.atproto.client, - ctx.atproto.resolver, - generated_client.IdentityResolveMiniDocParams(identifier: did), - None, - ) - { - Error(_) -> upstream_failure() - Ok(doc) -> - case is_safe_pds_url(doc.pds) { - // `doc.pds` comes from the did's document, so an unsafe endpoint is - // treated the same as an unresolvable one rather than being fetched. + case identity_resolver.resolve_one(ctx.atproto.identity, did) { + Error(Nil) -> upstream_failure() + Ok(identity) -> + case is_safe_pds_url(identity.pds) { + // `identity.pds` comes from the did's document, so an unsafe endpoint + // is treated the same as an unresolvable one rather than being fetched. False -> upstream_failure() - True -> fetch_blob(ctx, doc.pds, did, cid, dir, base) + True -> fetch_blob(ctx, identity.pds, did, cid, dir, base) } } } diff --git a/server/src/at_record_server/handlers/edit_inbox.gleam b/server/src/at_record_server/handlers/edit_inbox.gleam index f4eedf9..5b93740 100644 --- a/server/src/at_record_server/handlers/edit_inbox.gleam +++ b/server/src/at_record_server/handlers/edit_inbox.gleam @@ -43,10 +43,10 @@ pub fn list_proposals(req: Request, ctx: Context) -> Response { let proposals = edit_inbox.list_proposals( ctx.catalog, - client, session, own, ctx.variant_source, + ctx.atproto.identity, ) let query = wisp.get_query(req) let cursor = list.key_find(query, "cursor") |> option.from_result diff --git a/server/src/at_record_server/handlers/shelf.gleam b/server/src/at_record_server/handlers/shelf.gleam index ad051ff..79469c4 100644 --- a/server/src/at_record_server/handlers/shelf.gleam +++ b/server/src/at_record_server/handlers/shelf.gleam @@ -19,8 +19,8 @@ import at_record_server/covers import at_record_server/crate import at_record_server/discogs_client import at_record_server/event_log -import at_record_server/handle_resolver import at_record_server/handlers/cover_proxy +import at_record_server/identity_resolver import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/pagination import at_record_server/promotion @@ -139,9 +139,9 @@ fn encode_handle_map(handles: Dict(String, String)) -> json.Json { |> json.object } -// The actor (unauthenticated) path skips attribution lookups entirely: each -// foreign DID would otherwise cost an uncached serial plc.directory GET, and -// a crafted repo can hold up to a page's worth of them per request. +// The actor (unauthenticated) path skips attribution lookups entirely: a +// crafted repo can hold up to a page's worth of unique foreign DIDs, and a +// stranger's crate has no business exposing who else's records it adopted. fn via_handles_for( ctx: Context, public: Bool, @@ -178,12 +178,8 @@ fn via_handles( foreign_dids |> list.map(fn(pair) { pair.1 }) |> list.unique - |> list.filter_map(fn(did) { - handle_resolver.resolve_handle(ctx.atproto.client, did) - |> option.map(fn(handle) { #(did, handle) }) - |> option.to_result(Nil) - }) - |> dict.from_list + |> identity_resolver.resolve_many(ctx.atproto.identity, _) + |> dict.map_values(fn(_did, identity) { identity.handle }) foreign_dids |> list.filter_map(fn(pair) { dict.get(handles_by_did, pair.1) |> result.map(fn(h) { #(pair.0, h) }) diff --git a/server/src/at_record_server/shelf_owner.gleam b/server/src/at_record_server/shelf_owner.gleam index e1e1045..7c0d253 100644 --- a/server/src/at_record_server/shelf_owner.gleam +++ b/server/src/at_record_server/shelf_owner.gleam @@ -10,6 +10,7 @@ import at_record/gen/repo/list_records.{type RecordEntry} import at_record/gen/shelf/entry import at_record/storage.{type StoredItem} import at_record_server/context.{type Context} +import at_record_server/identity_resolver import at_record_server/url_guard.{is_safe_pds_url} import atproto/xrpc import gleam/dynamic/decode @@ -28,36 +29,14 @@ pub fn resolve_actor( ctx: Context, actor: String, ) -> Result(#(String, String, String), Nil) { - case - generated_client.identity_resolve_mini_doc( - ctx.atproto.client, - ctx.atproto.resolver, - generated_client.IdentityResolveMiniDocParams(identifier: actor), - None, - ) - { - Error(generated_client.IdentityResolveMiniDocTransport(err)) -> { - wisp.log_warning( - "shelf_owner: resolver unreachable for " - <> actor - <> ": " - <> xrpc.describe(err), - ) - Error(Nil) - } - Error(generated_client.IdentityResolveMiniDocUnexpected(status, _, _)) -> { - wisp.log_warning( - "shelf_owner: no such actor " - <> actor - <> " (resolver status " - <> int.to_string(status) - <> ")", - ) + case identity_resolver.resolve_one(ctx.atproto.identity, actor) { + Error(Nil) -> { + wisp.log_warning("shelf_owner: could not resolve actor " <> actor) Error(Nil) } - Ok(doc) -> - case is_safe_pds_url(doc.pds) { - True -> Ok(#(doc.did, doc.handle, doc.pds)) + Ok(identity) -> + case is_safe_pds_url(identity.pds) { + True -> Ok(#(identity.did, identity.handle, identity.pds)) False -> Error(Nil) } } diff --git a/server/src/at_record_server/wiring.gleam b/server/src/at_record_server/wiring.gleam index d3cfc3b..87c36c2 100644 --- a/server/src/at_record_server/wiring.gleam +++ b/server/src/at_record_server/wiring.gleam @@ -108,8 +108,10 @@ fn catalog_deps( /// Resolve one identifier (did or handle) to an `Identity` via Slingshot's /// `identity.resolveMiniDoc`, injected into `identity_resolver.Resolver` so -/// the resolver/cache layer stays free of any xrpc dependency. -fn identity_fetch( +/// the resolver/cache layer stays free of any xrpc dependency. Exported so +/// test support can build a `Resolver` backed by the same fake client a test +/// already wires up, instead of duplicating this glue. +pub fn identity_fetch( client: Client, resolver: String, ) -> fn(String) -> Result(Identity, Nil) { diff --git a/server/test/actor_shelf_test.gleam b/server/test/actor_shelf_test.gleam index a64c820..4e1280a 100644 --- a/server/test/actor_shelf_test.gleam +++ b/server/test/actor_shelf_test.gleam @@ -219,10 +219,10 @@ pub fn actor_shelf_folds_and_excludes_gone_entries_test() { assert titles == ["Spiderland", "Loveless"] } -// A foreign-DID release would normally send `via_handles` off to -// plc.directory; `network_client` panics on any host besides the resolver -// and the actor's own PDS, so this would fail loudly if the actor path -// still fanned out for attribution on someone else's crate. +// A foreign-DID release would normally send `via_handles` off to the +// identity resolver; `network_client` panics on any host besides the +// resolver and the actor's own PDS, so this would fail loudly if the actor +// path still fanned out for attribution on someone else's crate. pub fn actor_shelf_skips_via_handles_lookup_for_foreign_release_test() { let foreign_release = record("3fff", [ diff --git a/server/test/edit_inbox_test.gleam b/server/test/edit_inbox_test.gleam index 03202fa..57e09fd 100644 --- a/server/test/edit_inbox_test.gleam +++ b/server/test/edit_inbox_test.gleam @@ -6,11 +6,13 @@ import at_record_server/catalog/row.{type BrowseRow, BrowseRow} import at_record_server/catalog/source as catalog_source import at_record_server/catalog_deps.{type Deps, Deps} import at_record_server/edit_inbox.{type OwnRelease, OwnRelease} +import at_record_server/identity_cache.{Identity} +import at_record_server/identity_resolver import at_record_server/oauth/sessions.{type OauthSession} import atproto/constellation.{type Backlink, Backlink, BacklinksPage} import atproto/uri -import atproto/xrpc import atproto_core/xrpc as core_xrpc +import gleam/erlang/process import gleam/int import gleam/list import gleam/option.{None, Some} @@ -24,10 +26,13 @@ fn session() -> OauthSession { support.stub_session() } -/// A client whose every request fails, so `handle_resolver.resolve_handle` -/// degrades to `None` without needing a live PLC directory. -fn no_network_client() -> xrpc.Client { - xrpc.Client(send: fn(_req) { Error(core_xrpc.ConnectionFailed("offline")) }) +/// A `Resolver` whose `fetch` always fails, so proposer-handle resolution +/// degrades to `None` without needing a live network -- the identity-layer +/// equivalent of the old `no_network_client`. +fn offline_identity_resolver() -> identity_resolver.Resolver { + let assert Ok(cache) = + identity_cache.start(ttl_seconds: 3600, negative_ttl_seconds: 60) + identity_resolver.Resolver(cache:, fetch: fn(_) { Error(Nil) }) } /// [1, 2, .., count] @@ -204,10 +209,10 @@ pub fn own_authored_proposal_is_excluded_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) assert rows == [] } @@ -228,10 +233,10 @@ pub fn foreign_release_entity_proposal_is_included_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) let assert [ ProposalRow( @@ -275,10 +280,10 @@ pub fn subject_adoption_count_is_populated_when_the_target_release_is_indexed_te let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], indexed_source([owned.ref.uri], fn(_) { 3 }), + offline_identity_resolver(), ) let assert [ProposalRow(subject_adoption_count:, ..)] = rows assert subject_adoption_count == Some(3) @@ -300,10 +305,10 @@ pub fn subject_adoption_count_is_omitted_when_the_target_release_is_not_indexed_ let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) let assert [ProposalRow(subject_adoption_count:, ..)] = rows assert subject_adoption_count == None @@ -325,10 +330,10 @@ pub fn proposer_handle_resolution_failure_degrades_silently_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) let assert [ProposalRow(proposer_handle:, ..)] = rows assert proposer_handle == None @@ -346,10 +351,10 @@ pub fn non_release_entity_proposal_is_dropped_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) assert rows == [] } @@ -370,10 +375,10 @@ pub fn proposal_targeting_a_different_release_is_dropped_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) assert rows == [] } @@ -392,10 +397,10 @@ pub fn a_failed_backlinks_lookup_skips_that_release_rather_than_failing_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) assert rows == [] } @@ -434,10 +439,10 @@ pub fn release_fanout_is_capped_newest_first_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), owns, support.empty_variant_source(), + offline_identity_resolver(), ) assert list.length(rows) == edit_inbox.release_fanout_cap } @@ -456,10 +461,62 @@ pub fn merged_proposal_cap_applies_across_a_single_release_test() { let rows = edit_inbox.list_proposals( deps, - no_network_client(), session(), [owned], support.empty_variant_source(), + offline_identity_resolver(), ) assert list.length(rows) == edit_inbox.proposal_cap } + +// The same proposer DID files against two different own releases; the +// batched pre-resolve (collected across every release before the one +// `resolve_many`) must still call the underlying fetch only once. +pub fn several_proposals_sharing_one_proposer_did_resolve_with_one_fetch_test() { + let owned1 = own_release(1, None) + let owned2 = own_release(2, None) + let deps = + deps_with( + fn(subject) { + [ + Backlink( + did: other_did, + collection: catalog_edit.collection, + rkey: uri.rkey(subject), + ), + ] + }, + fn(edit_uri) { + let target_uri = + "at://" + <> own_did + <> "/" + <> catalog_release.collection + <> "/" + <> uri.rkey(edit_uri) + Some(#( + "bafyedit" <> uri.rkey(edit_uri), + edit_record("release", target_uri, Some(release_fields("x"))), + )) + }, + ) + let counter = process.new_subject() + let identity = + support.counting_identity_resolver(counter, fn(id) { + Ok(Identity(did: id, handle: "someone.test", pds: "https://pds.test")) + }) + let rows = + edit_inbox.list_proposals( + deps, + session(), + [owned1, owned2], + support.empty_variant_source(), + identity, + ) + assert list.length(rows) == 2 + assert support.drain_count(counter) == 1 + rows + |> list.each(fn(row) { + assert row.proposer_handle == Some("someone.test") + }) +} diff --git a/server/test/handle_resolver_test.gleam b/server/test/handle_resolver_test.gleam deleted file mode 100644 index a082939..0000000 --- a/server/test/handle_resolver_test.gleam +++ /dev/null @@ -1,20 +0,0 @@ -import at_record_server/handle_resolver -import gleam/option.{None, Some} - -pub fn handle_from_akas_picks_the_at_uri_test() { - let akas = [ - "https://bsky.social/profile/example", - "at://crate-digger.bsky.social", - ] - assert handle_resolver.handle_from_akas(akas) - == Some("crate-digger.bsky.social") -} - -pub fn handle_from_akas_none_when_no_at_uri_test() { - let akas = ["https://example.com/profile"] - assert handle_resolver.handle_from_akas(akas) == None -} - -pub fn handle_from_akas_empty_list_test() { - assert handle_resolver.handle_from_akas([]) == None -} diff --git a/server/test/shelf_via_handles_test.gleam b/server/test/shelf_via_handles_test.gleam new file mode 100644 index 0000000..fd9958d --- /dev/null +++ b/server/test/shelf_via_handles_test.gleam @@ -0,0 +1,151 @@ +//// `viaHandles` attribution on the own-shelf `shelf.listEntries` endpoint: +//// entries whose release was adopted from someone else's repo get that +//// repo's handle resolved through the batched identity resolver, deduped +//// per unique foreign DID rather than once per entry. + +import at_record/gen/defs.{CatalogRef, Snapshot} +import at_record/gen/shelf/entry.{ + type ShelfEntry, ShelfEntry, encode_shelf_entry, +} +import at_record_server/context.{type Context, Atproto, Context} +import at_record_server/handlers/shelf as shelf_handler +import at_record_server/identity_cache.{Identity} +import at_record_server/oauth/session_store +import at_record_server/oauth/sessions.{type OauthSession} +import atproto/xrpc +import gleam/bit_array +import gleam/dict +import gleam/dynamic/decode +import gleam/erlang/process +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 own_did = "did:plc:x" + +const foreign_did = "did:plc:foreign" + +fn a_session() -> OauthSession { + support.stub_session_with( + access_token: "at", + refresh_token: "rt", + expires_at: far_future, + ) +} + +fn rkey(n: Int) -> String { + "g" <> string.pad_start(int.to_string(n), 3, "0") +} + +fn foreign_entry(n: Int) -> ShelfEntry { + ShelfEntry( + ..support.blank_shelf_entry(), + action: "acquired", + snapshot: Some(Snapshot( + artist_display: "Artist " <> rkey(n), + cover: None, + format: None, + thumb_url: None, + title: "Title " <> rkey(n), + year: None, + )), + release: Some(CatalogRef( + cid: "bafyrel" <> rkey(n), + uri: "at://" + <> foreign_did + <> "/dev.mokkenstorm.crate.catalog.release/r" + <> rkey(n), + external_ids: None, + )), + ) +} + +fn record_json(n: Int) -> json.Json { + let uri = + "at://" <> own_did <> "/dev.mokkenstorm.crate.shelf.entry/" <> rkey(n) + json.object([ + #("uri", json.string(uri)), + #("cid", json.string("bafy" <> rkey(n))), + #("value", encode_shelf_entry(foreign_entry(n))), + ]) +} + +fn client_with_foreign_entries(count: Int) -> xrpc.Client { + let records = + list.repeat(Nil, count) |> list.index_map(fn(_, i) { record_json(i + 1) }) + 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 test_context(client: xrpc.Client) -> Context { + let cfg = support.stub_config_with(client, "r", "http://localhost:8080") + support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + fn(_req) { Error("unused") }, + [], + ) +} + +fn list_own_shelf(ctx: Context) -> String { + let assert Ok(id) = session_store.create(ctx.oauth.sessions, a_session()) + let req = + simulate.request(http.Get, support.xrpc("shelf.listEntries")) + |> simulate.cookie(session_cookie, id, wisp.Signed) + let resp = shelf_handler.list_shelf(req, ctx) + assert resp.status == 200 + simulate.read_body(resp) +} + +fn via_handles(body: String) -> dict.Dict(String, String) { + let assert Ok(handles) = + json.parse( + body, + decode.at(["viaHandles"], decode.dict(decode.string, decode.string)), + ) + handles +} + +pub fn entries_sharing_one_foreign_did_resolve_with_one_fetch_test() { + let count = 3 + let ctx = test_context(client_with_foreign_entries(count)) + let counter = process.new_subject() + let identity = + support.counting_identity_resolver(counter, fn(id) { + Ok(Identity(did: id, handle: "foreign.test", pds: "https://pds.test")) + }) + let ctx = Context(..ctx, atproto: Atproto(..ctx.atproto, identity:)) + let body = list_own_shelf(ctx) + assert support.drain_count(counter) == 1 + let handles = via_handles(body) + assert dict.size(handles) == count + handles + |> dict.values + |> list.each(fn(handle) { + assert handle == "foreign.test" + }) +} + +pub fn a_resolution_failure_omits_the_entry_from_via_handles_test() { + let ctx = test_context(client_with_foreign_entries(1)) + let identity = + support.counting_identity_resolver(process.new_subject(), fn(_id) { + Error(Nil) + }) + let ctx = Context(..ctx, atproto: Atproto(..ctx.atproto, identity:)) + let body = list_own_shelf(ctx) + assert dict.size(via_handles(body)) == 0 +} diff --git a/server/test/support.gleam b/server/test/support.gleam index a9dfb7c..04bd7a9 100644 --- a/server/test/support.gleam +++ b/server/test/support.gleam @@ -19,10 +19,12 @@ import at_record_server/oauth/keys import at_record_server/oauth/sessions import at_record_server/oauth/sessions_memory import at_record_server/oauth/store +import at_record_server/wiring import atproto/xrpc import atproto_core/xrpc as core_xrpc import gleam/dict import gleam/dynamic/decode +import gleam/erlang/process import gleam/json import gleam/option.{None} import gleam/result @@ -49,9 +51,11 @@ pub fn unreachable_catalog_deps() -> catalog_deps.Deps { } /// A `Resolver` over a real (empty) `identity_cache`, whose `fetch` panics: -/// tests that never resolve an identity never touch it; tests that do -/// should build their own `Resolver` with a fake `fetch` and override -/// `ctx.atproto.identity` via record update. +/// an explicit opt-in for a test asserting identity resolution never +/// happens on some path. Most tests get a working resolver for free from +/// `stub_context_with` instead (see `stub_identity_resolver`); override +/// `ctx.atproto.identity` via record update only when a test specifically +/// needs this guard, or a fake `fetch` of its own. pub fn unreachable_identity_resolver() -> identity_resolver.Resolver { let assert Ok(cache) = identity_cache.start(ttl_seconds: 3600, negative_ttl_seconds: 60) @@ -60,6 +64,49 @@ pub fn unreachable_identity_resolver() -> identity_resolver.Resolver { }) } +/// A `Resolver` over a fresh `identity_cache`, whose `fetch` mirrors +/// production wiring: `identity.resolveMiniDoc` via the same `client` and +/// resolver host a test's stub context already sets up. A test's existing +/// "resolver.test" branch in its fake `xrpc.Client` therefore also drives +/// `shelf_owner.resolve_actor`/`cover_proxy`/`via_handles` resolution, with +/// no extra wiring needed. +fn stub_identity_resolver( + client: xrpc.Client, + resolver: String, +) -> identity_resolver.Resolver { + let assert Ok(cache) = + identity_cache.start(ttl_seconds: 3600, negative_ttl_seconds: 60) + identity_resolver.Resolver( + cache:, + fetch: wiring.identity_fetch(client, resolver), + ) +} + +/// A `Resolver` whose `fetch` records every call on `counter` (one message +/// per call) before answering via `respond`, so a test can `drain_count` +/// afterwards to assert how many underlying fetches actually ran despite N +/// cache-deduped identifiers sharing one entry. +pub fn counting_identity_resolver( + counter: process.Subject(Nil), + respond: fn(String) -> Result(identity_cache.Identity, Nil), +) -> identity_resolver.Resolver { + let assert Ok(cache) = + identity_cache.start(ttl_seconds: 3600, negative_ttl_seconds: 60) + identity_resolver.Resolver(cache:, fetch: fn(id) { + process.send(counter, Nil) + respond(id) + }) +} + +/// Drains a `counting_identity_resolver`'s counter, returning how many +/// fetches actually happened. +pub fn drain_count(counter: process.Subject(Nil)) -> Int { + case process.receive(counter, 0) { + Ok(Nil) -> 1 + drain_count(counter) + Error(Nil) -> 0 + } +} + fn known_users_of(users: List(known_users.KnownUser)) -> known_users.Store { known_users.Store(upsert: fn(_) { Nil }, list: fn() { users }) } @@ -145,7 +192,7 @@ pub fn stub_context_with( atproto: Atproto( client: cfg.client, resolver: cfg.resolver, - identity: unreachable_identity_resolver(), + identity: stub_identity_resolver(cfg.client, cfg.resolver), ), discogs: Discogs(auth: None, creds: cfg.sessions, send: discogs_send), catalog:,