From 85fc153c3e419ca075f2f99df7e3711adc997efb Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 13 Jul 2026 22:01:55 +0200 Subject: [PATCH] test(server): extract shared test support module for context and fixtures --- server/test/browse_test.gleam | 13 +- server/test/cover_proxy_test.gleam | 45 +---- server/test/crate_test.gleam | 65 ++----- server/test/discogs_scan_test.gleam | 92 ++-------- server/test/edit_inbox_apply_test.gleam | 97 ++--------- server/test/edit_inbox_test.gleam | 33 +--- server/test/oauth_test.gleam | 115 ++----------- server/test/promotion_test.gleam | 172 ++++++++----------- server/test/support.gleam | 219 ++++++++++++++++++++++++ 9 files changed, 359 insertions(+), 492 deletions(-) create mode 100644 server/test/support.gleam diff --git a/server/test/browse_test.gleam b/server/test/browse_test.gleam index 6fee83a..9e0b395 100644 --- a/server/test/browse_test.gleam +++ b/server/test/browse_test.gleam @@ -9,6 +9,7 @@ import gleam/http/response import gleam/list import gleam/option.{type Option, None, Some} import gleam/string +import support fn row( uri uri: String, @@ -52,23 +53,13 @@ fn genesis( cid: "bafy" <> rkey, rkey:, value: ShelfEntry( - subject: None, + ..support.blank_shelf_entry(), action:, - snapshot: None, external_ids:, - media_grade: None, - sleeve_grade: None, - rating: None, - folder: None, - notes: None, release: release_uri |> option.map(fn(u) { CatalogRef(uri: u, cid: "bafyrel", external_ids: None) }), - price: None, - counterparty: None, - source: None, - created_at: "2026-01-01T00:00:00Z", ), ) } diff --git a/server/test/cover_proxy_test.gleam b/server/test/cover_proxy_test.gleam index 4e02e63..8568f66 100644 --- a/server/test/cover_proxy_test.gleam +++ b/server/test/cover_proxy_test.gleam @@ -3,21 +3,14 @@ //// for identity resolution, getBlob for the blob fetch. A cache hit must //// never touch either. -import at_record_server/catalog_deps -import at_record_server/catalog_index -import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/context.{type Context} import at_record_server/handlers/cover_proxy -import at_record_server/known_users -import at_record_server/oauth/config -import at_record_server/oauth/keys -import at_record_server/oauth/sessions_memory -import at_record_server/oauth/store import atproto/xrpc import envoy import gleam/bit_array import gleam/http/response -import gleam/option.{None} import simplifile +import support import wisp/simulate const did = "did:plc:pub" @@ -26,8 +19,6 @@ const cid = "bafycover123" const image_bytes = "totally-a-jpeg" -const resolver_host = "resolver.test" - const pds_host = "pds.test" fn resolve_body() -> String { @@ -73,37 +64,7 @@ fn panics_if_called_client() -> xrpc.Client { } fn test_context(client: xrpc.Client) -> Context { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() - let cfg = - config.new( - client:, - resolver: "https://" <> resolver_host, - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", - ) - Context( - web: Web(static_directory: "", base_url: "http://localhost:8080"), - atproto: Atproto(client:, resolver: "https://" <> resolver_host), - discogs: Discogs(auth: None, creds: cfg.sessions, send: fn(_req) { - panic as "not exercised by the cover proxy" - }), - catalog: catalog_deps.Deps( - backlinks: fn(_subject, _source) { panic as "not exercised" }, - fetch_release: fn(_at_uri) { None }, - fetch_edit: fn(_at_uri) { None }, - release_mbid: fn(_barcodes, _discogs_id) { None }, - ), - known_users: known_users.Store(upsert: fn(_) { Nil }, list: fn() { [] }), - catalog_index: catalog_index.Store( - upsert: fn(_) { Nil }, - delete: fn(_) { Nil }, - list: fn() { [] }, - ), - oauth: cfg, - ) + support.stub_context(support.stub_config(client)) } fn fresh_cache_dir(name: String) -> String { diff --git a/server/test/crate_test.gleam b/server/test/crate_test.gleam index 55df65d..41d9581 100644 --- a/server/test/crate_test.gleam +++ b/server/test/crate_test.gleam @@ -4,11 +4,10 @@ import at_record/gen/repo/strong_ref.{RepoStrongRef} import at_record/gen/shelf/entry.{type ShelfEntry, ShelfEntry} import at_record/storage.{type StoredItem, StoredItem} import at_record_server/crate -import gleam/dynamic/decode import gleam/json import gleam/list import gleam/option.{None, Some} -import gleam/result +import support fn event_uri(rkey: String) -> String { "at://did/dev.mokkenstorm.crate.shelf.entry/" <> rkey @@ -37,20 +36,12 @@ fn ev_with_source( cid: "cid-" <> rkey, rkey:, value: ShelfEntry( + ..support.blank_shelf_entry(), subject: option.map(subject_rkey, fn(genesis) { RepoStrongRef(uri: event_uri(genesis), cid: "cid-" <> genesis) }), action:, - snapshot: None, - external_ids: None, media_grade:, - sleeve_grade: None, - rating: None, - folder: None, - notes: None, - release: None, - price: None, - counterparty: None, source:, created_at: "t", ), @@ -125,34 +116,7 @@ pub fn fold_carries_genesis_source_test() { } fn blank_release() -> catalog_release.CatalogRelease { - catalog_release.CatalogRelease( - title: "Spiderland", - artist_display: None, - created_at: "2026-01-01T00:00:00Z", - external_ids: None, - released: None, - thumb_url: None, - cover: None, - based_on: None, - country: None, - credited_artists: None, - formats: None, - genres: None, - identifiers: None, - labels: None, - master: None, - styles: None, - supersedes: None, - tracklist: None, - ) -} - -fn field_string(body: String, path: List(String)) -> Result(String, Nil) { - json.parse(body, decode.at(path, decode.string)) |> result.replace_error(Nil) -} - -fn field_present(body: String, path: List(String)) -> Bool { - json.parse(body, decode.at(path, decode.dynamic)) |> result.is_ok + support.blank_catalog_release() } pub fn encode_release_info_includes_present_display_fields_test() { @@ -166,18 +130,18 @@ pub fn encode_release_info_includes_present_display_fields_test() { released: Some("1991-03-27"), ) let body = crate.encode_release_info(release) |> json.to_string - assert field_string(body, ["artistDisplay"]) == Ok("Slint") - assert field_string(body, ["country"]) == Ok("US") - assert field_string(body, ["released"]) == Ok("1991-03-27") + assert support.field_string(body, ["artistDisplay"]) == Ok("Slint") + assert support.field_string(body, ["country"]) == Ok("US") + assert support.field_string(body, ["released"]) == Ok("1991-03-27") } pub fn encode_release_info_omits_absent_fields_test() { let body = crate.encode_release_info(blank_release()) |> json.to_string - assert field_present(body, ["artistDisplay"]) == False - assert field_present(body, ["genres"]) == False - assert field_present(body, ["styles"]) == False - assert field_present(body, ["country"]) == False - assert field_present(body, ["released"]) == False + assert support.field_present(body, ["artistDisplay"]) == False + assert support.field_present(body, ["genres"]) == False + assert support.field_present(body, ["styles"]) == False + assert support.field_present(body, ["country"]) == False + assert support.field_present(body, ["released"]) == False } fn blank_entry() -> crate.CrateEntry { @@ -206,11 +170,12 @@ pub fn encode_entry_includes_source_when_present_test() { crate.CrateEntry(..blank_entry(), source: Some(a_source("scan"))), ) |> json.to_string - assert field_string(body, ["source", "origin"]) == Ok("scan") - assert field_string(body, ["source", "external", "provider"]) == Ok("discogs") + assert support.field_string(body, ["source", "origin"]) == Ok("scan") + assert support.field_string(body, ["source", "external", "provider"]) + == Ok("discogs") } pub fn encode_entry_omits_source_when_absent_test() { let body = crate.encode_entry(blank_entry()) |> json.to_string - assert field_present(body, ["source"]) == False + assert support.field_present(body, ["source"]) == False } diff --git a/server/test/discogs_scan_test.gleam b/server/test/discogs_scan_test.gleam index 289581d..21998a8 100644 --- a/server/test/discogs_scan_test.gleam +++ b/server/test/discogs_scan_test.gleam @@ -5,29 +5,22 @@ //// `barcode` param (lookup) or a `q` param (text search); the MusicBrainz //// call goes through the atproto xrpc client, stubbed independently. -import at_record_server/catalog_deps -import at_record_server/catalog_index -import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/context.{type Context} import at_record_server/discogs_client import at_record_server/handlers/discogs as discogs_handler import at_record_server/known_users.{type KnownUser, KnownUser} import at_record_server/oauth/config -import at_record_server/oauth/keys import at_record_server/oauth/session_store import at_record_server/oauth/sessions -import at_record_server/oauth/sessions_memory -import at_record_server/oauth/store import atproto/xrpc import gleam/bit_array import gleam/http import gleam/http/request import gleam/http/response import gleam/list -import gleam/option.{None} import gleam/result import gleam/string -import gose -import kryptos/ec +import support import wisp import wisp/simulate @@ -76,15 +69,7 @@ fn mb_fails() -> xrpc.Client { } fn a_session() -> sessions.OauthSession { - sessions.OauthSession( - did: "did:plc:x", - handle: "h.test", - pds: "https://pds.example", - issuer: "https://as.example", - token_endpoint: "https://as.example/token", - client_id: "cid", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), + support.stub_session_with( access_token: "at", refresh_token: "rt", expires_at: far_future, @@ -95,35 +80,13 @@ fn test_context( mb: xrpc.Client, discogs_send: discogs_client.Sender, ) -> #(Context, config.Config) { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() - let cfg = - config.new( - client: mb, - resolver: "r", - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", - ) + let cfg = support.stub_config_with(mb, "r", "http://localhost:8080") let ctx = - Context( - web: Web(static_directory: "", base_url: "http://localhost:8080"), - atproto: Atproto(client: mb, resolver: "r"), - discogs: Discogs(auth: None, creds: cfg.sessions, send: discogs_send), - catalog: catalog_deps.Deps( - backlinks: fn(_subject, _source) { panic as "not exercised by scan" }, - fetch_release: fn(_at_uri) { None }, - fetch_edit: fn(_at_uri) { None }, - release_mbid: fn(_barcodes, _discogs_id) { None }, - ), - known_users: known_users.Store(upsert: fn(_) { Nil }, list: fn() { [] }), - catalog_index: catalog_index.Store( - upsert: fn(_) { Nil }, - delete: fn(_) { Nil }, - list: fn() { [] }, - ), - oauth: cfg, + support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + discogs_send, + [], ) #(ctx, cfg) } @@ -182,10 +145,6 @@ fn a_known_user() -> KnownUser { KnownUser(did: "did:plc:pub", handle: "pub.test", pds: "https://pds.test") } -fn known_users_of(users: List(KnownUser)) -> known_users.Store { - known_users.Store(upsert: fn(_) { Nil }, list: fn() { users }) -} - /// A client whose `send` answers a `listRecords` fan-out call with `body`; /// used as `ctx.atproto.client` so it also stands in for the MusicBrainz /// client (unreachable in these tests: a network hit never falls through, @@ -207,35 +166,14 @@ fn test_context_with_users( discogs_send: discogs_client.Sender, users: List(KnownUser), ) -> #(Context, config.Config) { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() let cfg = - config.new( - client: atproto_client, - resolver: "r", - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", - ) + support.stub_config_with(atproto_client, "r", "http://localhost:8080") let ctx = - Context( - web: Web(static_directory: "", base_url: "http://localhost:8080"), - atproto: Atproto(client: atproto_client, resolver: "r"), - discogs: Discogs(auth: None, creds: cfg.sessions, send: discogs_send), - catalog: catalog_deps.Deps( - backlinks: fn(_subject, _source) { panic as "not exercised by scan" }, - fetch_release: fn(_at_uri) { None }, - fetch_edit: fn(_at_uri) { None }, - release_mbid: fn(_barcodes, _discogs_id) { None }, - ), - known_users: known_users_of(users), - catalog_index: catalog_index.Store( - upsert: fn(_) { Nil }, - delete: fn(_) { Nil }, - list: fn() { [] }, - ), - oauth: cfg, + support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + discogs_send, + users, ) #(ctx, cfg) } diff --git a/server/test/edit_inbox_apply_test.gleam b/server/test/edit_inbox_apply_test.gleam index d504a6b..9b92933 100644 --- a/server/test/edit_inbox_apply_test.gleam +++ b/server/test/edit_inbox_apply_test.gleam @@ -7,18 +7,12 @@ import at_record/gen/catalog/release as catalog_release import at_record/gen/defs.{CatalogRef} import at_record/gen/shelf/entry.{type ShelfEntry, ShelfEntry} import at_record_server/catalog_deps.{type Deps, Deps} -import at_record_server/catalog_index -import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/context.{type Context} import at_record_server/handlers/edit_inbox.{ Applied, CidMismatch, NotYourRecord, ProposalNotFound, ReleaseNotCurrent, apply, } -import at_record_server/known_users -import at_record_server/oauth/config -import at_record_server/oauth/keys -import at_record_server/oauth/sessions.{type OauthSession, OauthSession} -import at_record_server/oauth/sessions_memory -import at_record_server/oauth/store +import at_record_server/oauth/sessions.{type OauthSession} import atproto/xrpc import gleam/bit_array import gleam/dynamic/decode @@ -29,8 +23,7 @@ import gleam/json import gleam/option.{None, Some} import gleam/result import gleam/uri as gleam_uri -import gose -import kryptos/ec +import support const own_did = "did:plc:me" @@ -49,73 +42,25 @@ const proposal_uri = "at://did:plc:someone/dev.mokkenstorm.crate.catalog.edit/eP const proposal_cid = "bafyproposal" fn session() -> OauthSession { - OauthSession( - did: own_did, - handle: "me.test", - pds: "https://pds.test", - issuer: "https://as.test", - token_endpoint: "https://as.test/token", - client_id: "client", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), - access_token: "token", - refresh_token: "refresh", - expires_at: 0, - ) + support.stub_session() } fn stub_context(catalog: Deps) -> Context { - let assert Ok(pending) = store.start() - let assert Ok(sess) = sessions_memory.start() - let cfg = - config.new( - client: xrpc.Client(send: fn(_req) { Error("unused") }), - resolver: "https://resolver.test", - store: pending, - sessions: sess, - key: keys.load(), - base_url: "http://localhost:8080", - ) - Context( - web: Web(static_directory: "", base_url: "http://localhost:8080"), - atproto: Atproto( - client: xrpc.Client(send: fn(_req) { Error("unused") }), - resolver: "https://resolver.test", - ), - discogs: Discogs(auth: None, creds: cfg.sessions, send: fn(_req) { - Error("unused") - }), - catalog:, - known_users: known_users.Store(upsert: fn(_) { Nil }, list: fn() { [] }), - catalog_index: catalog_index.Store( - upsert: fn(_) { Nil }, - delete: fn(_) { Nil }, - list: fn() { [] }, - ), - oauth: cfg, + support.stub_context_with( + support.stub_config(support.unreachable_client()), + catalog, + fn(_req) { Error("unused") }, + [], ) } fn old_release() -> catalog_release.CatalogRelease { catalog_release.CatalogRelease( - title: "Spiderland", + ..support.blank_catalog_release(), artist_display: Some("Slint"), - created_at: "2026-01-01T00:00:00Z", - external_ids: None, released: Some("1991"), country: Some("US"), genres: Some(["Rock"]), - styles: None, - thumb_url: None, - cover: None, - based_on: None, - credited_artists: None, - formats: None, - identifiers: None, - labels: None, - master: None, - supersedes: None, - tracklist: None, ) } @@ -149,24 +94,12 @@ fn a_proposal(target_uri: String) -> catalog_edit.CatalogEdit { fn genesis_entry() -> ShelfEntry { ShelfEntry( - subject: None, - action: "acquired", - snapshot: None, - external_ids: None, - media_grade: None, - sleeve_grade: None, - rating: None, - folder: None, - notes: None, + ..support.blank_shelf_entry(), release: Some(CatalogRef( uri: old_release_uri, cid: old_release_cid, external_ids: None, )), - price: None, - counterparty: None, - source: None, - created_at: "2026-01-01T00:00:00Z", ) } @@ -309,9 +242,9 @@ pub fn apply_happy_path_mints_supersedes_and_repoints_the_entry_test() { deps_with_edit(proposal_uri, proposal_cid, a_proposal(old_release_uri)) let client = stub_client(fn(body) { - assert field_string(body, ["record", "title"]) + assert support.field_string(body, ["record", "title"]) == Ok("Spiderland (Remastered)") - assert field_string(body, ["record", "supersedes", "uri"]) + assert support.field_string(body, ["record", "supersedes", "uri"]) == Ok(old_release_uri) }) let result = @@ -379,7 +312,3 @@ pub fn apply_rejects_a_target_that_is_no_longer_current_test() { ) assert result == Error(ReleaseNotCurrent) } - -fn field_string(body: String, path: List(String)) -> Result(String, Nil) { - json.parse(body, decode.at(path, decode.string)) |> result.replace_error(Nil) -} diff --git a/server/test/edit_inbox_test.gleam b/server/test/edit_inbox_test.gleam index 25eea8c..08bb798 100644 --- a/server/test/edit_inbox_test.gleam +++ b/server/test/edit_inbox_test.gleam @@ -4,34 +4,21 @@ import at_record/gen/catalog/release as catalog_release import at_record/gen/defs.{CatalogRef} import at_record_server/catalog_deps.{type Deps, Deps} import at_record_server/edit_inbox.{type OwnRelease, OwnRelease} -import at_record_server/oauth/sessions.{type OauthSession, OauthSession} +import at_record_server/oauth/sessions.{type OauthSession} import atproto/constellation.{type Backlink, Backlink, BacklinksPage} import atproto/uri import atproto/xrpc import gleam/int import gleam/list import gleam/option.{None, Some} -import gose -import kryptos/ec +import support const own_did = "did:plc:me" const other_did = "did:plc:someone" fn session() -> OauthSession { - OauthSession( - did: own_did, - handle: "me.test", - pds: "https://pds.test", - issuer: "https://as.test", - token_endpoint: "https://as.test/token", - client_id: "client", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), - access_token: "token", - refresh_token: "refresh", - expires_at: 0, - ) + support.stub_session() } /// A client whose every request fails, so `handle_resolver.resolve_handle` @@ -50,24 +37,12 @@ fn release( created_at: String, ) -> catalog_release.CatalogRelease { catalog_release.CatalogRelease( + ..support.blank_catalog_release(), title:, - artist_display: None, created_at:, - external_ids: None, released: Some("1991"), country: Some("US"), genres: Some(["Rock"]), - styles: None, - thumb_url: None, - cover: None, - based_on: None, - credited_artists: None, - formats: None, - identifiers: None, - labels: None, - master: None, - supersedes: None, - tracklist: None, ) } diff --git a/server/test/oauth_test.gleam b/server/test/oauth_test.gleam index 34767fc..2673457 100644 --- a/server/test/oauth_test.gleam +++ b/server/test/oauth_test.gleam @@ -1,8 +1,6 @@ -import at_record_server/catalog_deps import at_record_server/catalog_index -import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/context.{type Context, Context} import at_record_server/handlers/oauth as oauth_handler -import at_record_server/known_users import at_record_server/oauth/assertion import at_record_server/oauth/authed import at_record_server/oauth/config @@ -26,6 +24,7 @@ import gleam/result import gleam/string import gose import kryptos/ec +import support import wisp import wisp/simulate @@ -108,17 +107,11 @@ pub fn client_assertion_is_signed_with_kid_and_audience_test() { } pub fn config_localhost_is_public_client_test() { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() - let stub = xrpc.Client(send: fn(_req) { Error("unused") }) let cfg = - config.new( - client: stub, - resolver: "https://resolver", - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", + support.stub_config_with( + support.unreachable_client(), + "https://resolver", + "http://localhost:8080", ) assert cfg.confidential == False assert string.starts_with(cfg.client_id, "http://localhost?") @@ -126,17 +119,11 @@ pub fn config_localhost_is_public_client_test() { } pub fn config_https_is_confidential_client_test() { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() - let stub = xrpc.Client(send: fn(_req) { Error("unused") }) let cfg = - config.new( - client: stub, - resolver: "https://resolver", - store: st, - sessions: ss, - key: keys.load(), - base_url: "https://app.example", + support.stub_config_with( + support.unreachable_client(), + "https://resolver", + "https://app.example", ) assert cfg.confidential == True assert cfg.client_id == "https://app.example/client-metadata.json" @@ -146,21 +133,11 @@ pub fn config_https_is_confidential_client_test() { const token_response = "{\"access_token\":\"at\",\"refresh_token\":\"rt\",\"token_type\":\"DPoP\",\"expires_in\":3600,\"sub\":\"did:plc:abc\",\"scope\":\"atproto\"}" pub fn token_exchange_parses_dpop_bound_tokens_test() { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() let stub = xrpc.Client(send: fn(_req) { Ok(response.Response(200, [], bit_array.from_string(token_response))) }) - let cfg = - config.new( - client: stub, - resolver: "r", - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", - ) + let cfg = support.stub_config_with(stub, "r", "http://localhost:8080") let flow = store.PendingFlow( handle: "h.test", @@ -182,15 +159,7 @@ pub fn token_exchange_parses_dpop_bound_tokens_test() { pub fn sessions_create_get_delete_roundtrip_test() { let assert Ok(ss) = sessions_memory.start() let session = - sessions.OauthSession( - did: "did:plc:x", - handle: "h.test", - pds: "https://pds", - issuer: "https://as", - token_endpoint: "https://as/token", - client_id: "cid", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), + support.stub_session_with( access_token: "at", refresh_token: "rt", expires_at: 0, @@ -205,35 +174,14 @@ pub fn sessions_create_get_delete_roundtrip_test() { const far_future = 9_999_999_999 fn test_cfg(stub: xrpc.Client) -> config.Config { - let assert Ok(st) = store.start() - let assert Ok(ss) = sessions_memory.start() - config.new( - client: stub, - resolver: "r", - store: st, - sessions: ss, - key: keys.load(), - base_url: "http://localhost:8080", - ) + support.stub_config_with(stub, "r", "http://localhost:8080") } fn test_session( access_token: String, expires_at: Int, ) -> sessions.OauthSession { - sessions.OauthSession( - did: "did:plc:x", - handle: "h.test", - pds: "https://pds.example", - issuer: "https://as.example", - token_endpoint: "https://as.example/token", - client_id: "cid", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), - access_token:, - refresh_token: "rt", - expires_at:, - ) + support.stub_session_with(access_token:, refresh_token: "rt", expires_at:) } pub fn authed_attaches_dpop_authorization_and_ath_test() { @@ -346,32 +294,7 @@ pub fn authed_ignores_nonce_signal_in_binary_bodies_test() { const state_cookie = "ar_oauth_state" fn test_context(cfg: config.Config) -> Context { - Context( - web: Web(static_directory: "", base_url: "http://localhost:8080"), - atproto: Atproto(client: cfg.client, resolver: "r"), - discogs: Discogs(auth: None, creds: cfg.sessions, send: fn(_req) { - Error("unused") - }), - catalog: catalog_deps.Deps( - backlinks: fn(_subject, _source) { - panic as "not exercised by oauth handler tests" - }, - fetch_release: fn(_at_uri) { None }, - fetch_edit: fn(_at_uri) { None }, - release_mbid: fn(_barcodes, _discogs_id) { None }, - ), - known_users: known_users.Store(upsert: fn(_) { Nil }, list: fn() { [] }), - catalog_index: catalog_index.Store( - upsert: fn(_) { Nil }, - delete: fn(_) { Nil }, - list: fn() { [] }, - ), - oauth: cfg, - ) -} - -fn unreachable_client() -> xrpc.Client { - xrpc.Client(send: fn(_req) { Error("unused") }) + support.stub_context(cfg) } /// A request carrying the `ar_oauth_state` cookie signed for this process, @@ -384,7 +307,7 @@ fn request_with_state_cookie(path: String, state: String) -> wisp.Request { } pub fn callback_as_error_passes_code_through_test() { - let ctx = test_context(test_cfg(unreachable_client())) + let ctx = test_context(test_cfg(support.unreachable_client())) let req = simulate.request(http.Get, "/api/oauth/callback?error=access_denied") let resp = oauth_handler.callback(req, ctx) @@ -393,7 +316,7 @@ pub fn callback_as_error_passes_code_through_test() { } pub fn callback_missing_code_or_state_redirects_bad_callback_test() { - let ctx = test_context(test_cfg(unreachable_client())) + let ctx = test_context(test_cfg(support.unreachable_client())) let req = simulate.request(http.Get, "/api/oauth/callback") let resp = oauth_handler.callback(req, ctx) assert resp.status == 303 @@ -401,7 +324,7 @@ pub fn callback_missing_code_or_state_redirects_bad_callback_test() { } pub fn callback_without_matching_cookie_redirects_state_mismatch_test() { - let ctx = test_context(test_cfg(unreachable_client())) + let ctx = test_context(test_cfg(support.unreachable_client())) let req = simulate.request(http.Get, "/api/oauth/callback?code=abc&state=xyz") let resp = oauth_handler.callback(req, ctx) assert resp.status == 303 @@ -409,7 +332,7 @@ pub fn callback_without_matching_cookie_redirects_state_mismatch_test() { } pub fn callback_token_exchange_failure_redirects_test() { - let cfg = test_cfg(unreachable_client()) + let cfg = test_cfg(support.unreachable_client()) let ctx = test_context(cfg) let state = "test-state" let issuer = "https://as.example" diff --git a/server/test/promotion_test.gleam b/server/test/promotion_test.gleam index 72bded7..531ffee 100644 --- a/server/test/promotion_test.gleam +++ b/server/test/promotion_test.gleam @@ -8,7 +8,7 @@ import at_record_server/catalog_entities.{type Entities, Entities, OwnArtist} import at_record_server/discogs_client.{ type ReleaseDetails, DetailArtist, Release, ReleaseDetails, } -import at_record_server/oauth/sessions.{type OauthSession, OauthSession} +import at_record_server/oauth/sessions.{type OauthSession} import at_record_server/promotion.{OwnRelease, Promoted} import atproto/constellation.{Backlink, BacklinksPage} import atproto/xrpc @@ -20,8 +20,7 @@ import gleam/json import gleam/list import gleam/option.{None, Some} import gleam/result -import gose -import kryptos/ec +import support fn no_entities() -> Entities { Entities(artists: dict.new(), genres: dict.new()) @@ -44,19 +43,7 @@ const own_did = "did:plc:me" const other_did = "did:plc:someone" fn session() -> OauthSession { - OauthSession( - did: own_did, - handle: "me.test", - pds: "https://pds.test", - issuer: "https://as.test", - token_endpoint: "https://as.test/token", - client_id: "client", - confidential: False, - dpop_key: gose.generate_ec(ec.P256), - access_token: "token", - refresh_token: "refresh", - expires_at: 0, - ) + support.stub_session() } fn release(id: Int) -> discogs_client.Release { @@ -116,12 +103,7 @@ fn entities_with_slint(ids: List(defs.ExternalId)) -> Entities { /// Deps that fail the test if the network is touched. fn offline_deps() -> Deps { - Deps( - backlinks: fn(_, _) { panic as "backlinks must not be called" }, - fetch_release: fn(_) { panic as "fetch_release must not be called" }, - fetch_edit: fn(_) { panic as "fetch_edit must not be called" }, - release_mbid: fn(_, _) { panic as "release_mbid must not be called" }, - ) + support.unreachable_catalog_deps() } /// Deps whose backlink index is empty, so adopt_or_mint falls through to mint. @@ -192,24 +174,8 @@ fn minting_client(uri: String) -> xrpc.Client { fn remote_release(discogs_id id: Int) -> catalog_release.CatalogRelease { catalog_release.CatalogRelease( - title: "Spiderland", - artist_display: None, - created_at: "2026-01-01T00:00:00Z", + ..support.blank_catalog_release(), external_ids: Some([discogs_client.external_id(release(id))]), - released: None, - thumb_url: None, - cover: None, - based_on: None, - country: None, - credited_artists: None, - formats: None, - genres: None, - identifiers: None, - labels: None, - master: None, - styles: None, - supersedes: None, - tracklist: None, ) } @@ -329,51 +295,28 @@ pub fn constellation_outage_still_mints_test() { let assert Some(Promoted(origin: "promotion", ..)) = outcome.promoted } -fn field_string(body: String, path: List(String)) -> Result(String, Nil) { - json.parse(body, decode.at(path, decode.string)) |> result.replace_error(Nil) -} - -fn field_strings( - body: String, - path: List(String), -) -> Result(List(String), Nil) { - json.parse(body, decode.at(path, decode.list(decode.string))) - |> result.replace_error(Nil) -} - -fn field_nested( - body: String, - path: List(String), - inner: List(String), -) -> Result(List(String), Nil) { - json.parse( - body, - decode.at(path, decode.list(decode.at(inner, decode.string))), - ) - |> result.replace_error(Nil) -} - -fn field_present(body: String, path: List(String)) -> Bool { - json.parse(body, decode.at(path, decode.dynamic)) |> result.is_ok -} - pub fn mint_with_details_enriches_release_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_release.collection { False -> Nil True -> { - assert field_string(body, ["record", "artistDisplay"]) == Ok("Slint") - assert field_strings(body, ["record", "genres"]) == Ok(["Rock"]) - assert field_strings(body, ["record", "styles"]) == Ok(["Post Rock"]) - assert field_string(body, ["record", "country"]) == Ok("US") - assert field_string(body, ["record", "released"]) == Ok("1991-03-27") - assert field_nested(body, ["record", "identifiers"], ["type"]) + assert support.field_string(body, ["record", "artistDisplay"]) + == Ok("Slint") + assert support.field_strings(body, ["record", "genres"]) + == Ok(["Rock"]) + assert support.field_strings(body, ["record", "styles"]) + == Ok(["Post Rock"]) + assert support.field_string(body, ["record", "country"]) == Ok("US") + assert support.field_string(body, ["record", "released"]) + == Ok("1991-03-27") + assert support.field_nested(body, ["record", "identifiers"], ["type"]) == Ok(["Barcode"]) - assert field_nested(body, ["record", "identifiers"], ["value"]) + assert support.field_nested(body, ["record", "identifiers"], ["value"]) == Ok(["12345"]) - assert field_nested(body, ["record", "creditedArtists"], [ + assert support.field_nested(body, ["record", "creditedArtists"], [ "artist", "uri", ]) |> result.map(list.length) @@ -400,10 +343,11 @@ pub fn mint_with_details_enriches_release_test() { pub fn mint_without_details_sets_only_artist_display_test() { let client = inspecting_client(fn(body) { - assert field_string(body, ["record", "artistDisplay"]) == Ok("Slint") - assert field_present(body, ["record", "genres"]) == False - assert field_present(body, ["record", "country"]) == False - assert field_present(body, ["record", "creditedArtists"]) == False + assert support.field_string(body, ["record", "artistDisplay"]) + == Ok("Slint") + assert support.field_present(body, ["record", "genres"]) == False + assert support.field_present(body, ["record", "country"]) == False + assert support.field_present(body, ["record", "creditedArtists"]) == False }) let outcome = promotion.adopt_or_mint( @@ -423,15 +367,18 @@ pub fn mint_without_details_sets_only_artist_display_test() { pub fn mint_with_resolved_mbid_adds_musicbrainz_external_id_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_release.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs", "musicbrainz"]) - assert field_nested(body, ["record", "externalIds"], ["id"]) + assert support.field_nested(body, ["record", "externalIds"], ["id"]) == Ok(["42", "mbid-abc"]) - assert field_nested(body, ["record", "externalIds"], ["url"]) + assert support.field_nested(body, ["record", "externalIds"], ["url"]) == Ok([ "https://www.discogs.com/release/42", "https://musicbrainz.org/release/mbid-abc", @@ -462,11 +409,14 @@ pub fn mint_with_resolved_mbid_adds_musicbrainz_external_id_test() { pub fn mint_with_unresolved_mbid_keeps_only_discogs_external_id_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_release.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs"]) } } @@ -493,13 +443,16 @@ fn details_with_artist_name(name: String) -> ReleaseDetails { pub fn mint_attaches_musicbrainz_id_to_exact_matching_artist_credit_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_artist.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs", "musicbrainz"]) - assert field_nested(body, ["record", "externalIds"], ["id"]) + assert support.field_nested(body, ["record", "externalIds"], ["id"]) == Ok(["1", "artist-mbid-1"]) } } @@ -529,13 +482,16 @@ pub fn mint_attaches_musicbrainz_id_to_exact_matching_artist_credit_test() { pub fn mint_attaches_musicbrainz_id_via_token_match_on_punctuation_drift_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_artist.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs", "musicbrainz"]) - assert field_nested(body, ["record", "externalIds"], ["id"]) + assert support.field_nested(body, ["record", "externalIds"], ["id"]) == Ok(["1", "artist-mbid-2"]) } } @@ -566,11 +522,14 @@ pub fn mint_attaches_musicbrainz_id_via_token_match_on_punctuation_drift_test() pub fn mint_leaves_artist_without_musicbrainz_id_on_name_mismatch_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_artist.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs"]) } } @@ -600,18 +559,21 @@ pub fn mint_leaves_artist_without_musicbrainz_id_on_name_mismatch_test() { pub fn dedup_hit_backfills_musicbrainz_id_via_put_record_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_artist.collection { False -> Nil True -> { // A putRecord at the existing rkey, not a fresh createRecord. - assert field_string(body, ["rkey"]) == Ok("3art") - assert field_string(body, ["record", "name"]) == Ok("Slint") - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_string(body, ["rkey"]) == Ok("3art") + assert support.field_string(body, ["record", "name"]) == Ok("Slint") + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs", "musicbrainz"]) - assert field_nested(body, ["record", "externalIds"], ["id"]) + assert support.field_nested(body, ["record", "externalIds"], ["id"]) == Ok(["1", "artist-mbid-1"]) - assert field_nested(body, ["record", "externalIds"], ["url"]) + assert support.field_nested(body, ["record", "externalIds"], ["url"]) == Ok([ "https://www.discogs.com/artist/1", "https://musicbrainz.org/artist/artist-mbid-1", @@ -647,7 +609,8 @@ pub fn dedup_hit_backfills_musicbrainz_id_via_put_record_test() { pub fn dedup_hit_with_existing_musicbrainz_id_is_not_touched_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") assert collection != catalog_artist.collection }) let deps = @@ -710,11 +673,14 @@ pub fn failed_backfill_still_mints_release_test() { pub fn mint_leaves_artist_without_musicbrainz_id_on_mb_miss_test() { let client = inspecting_client(fn(body) { - let collection = field_string(body, ["collection"]) |> result.unwrap("") + let collection = + support.field_string(body, ["collection"]) |> result.unwrap("") case collection == catalog_artist.collection { False -> Nil True -> { - assert field_nested(body, ["record", "externalIds"], ["provider"]) + assert support.field_nested(body, ["record", "externalIds"], [ + "provider", + ]) == Ok(["discogs"]) } } diff --git a/server/test/support.gleam b/server/test/support.gleam new file mode 100644 index 0000000..c4fe77d --- /dev/null +++ b/server/test/support.gleam @@ -0,0 +1,219 @@ +//// Shared test fixtures: stub `Config`/`Context` builders, default record +//// literals for the domain types tests repeatedly hand-build +//// (`CatalogRelease`, `ShelfEntry`, `OauthSession`), and the small JSON body +//// assertion helpers used across the mint/apply handler tests. Doesn't end +//// in `_test`, so gleeunit never picks it up as a test module. + +import at_record/gen/catalog/release as catalog_release +import at_record/gen/shelf/entry +import at_record_server/catalog_deps +import at_record_server/catalog_index +import at_record_server/context.{type Context, Atproto, Context, Discogs, Web} +import at_record_server/discogs_client +import at_record_server/known_users +import at_record_server/oauth/config +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 atproto/xrpc +import gleam/dynamic/decode +import gleam/json +import gleam/option.{None} +import gleam/result +import gose +import kryptos/ec + +pub fn unreachable_client() -> xrpc.Client { + xrpc.Client(send: fn(_req) { Error("unused") }) +} + +pub fn unreachable_catalog_deps() -> catalog_deps.Deps { + catalog_deps.Deps( + backlinks: fn(_, _) { panic as "backlinks must not be called" }, + fetch_release: fn(_) { panic as "fetch_release must not be called" }, + fetch_edit: fn(_) { panic as "fetch_edit must not be called" }, + release_mbid: fn(_, _) { panic as "release_mbid must not be called" }, + ) +} + +fn known_users_of(users: List(known_users.KnownUser)) -> known_users.Store { + known_users.Store(upsert: fn(_) { Nil }, list: fn() { users }) +} + +fn empty_catalog_index() -> catalog_index.Store { + catalog_index.Store(upsert: fn(_) { Nil }, delete: fn(_) { Nil }, list: fn() { + [] + }) +} + +/// A `Config` wired to in-memory pending-flow/session stores, so tests never +/// touch a real database. `resolver`/`base_url` default to a plain localhost +/// dev client; use `stub_config_with` to exercise the confidential-client +/// (https origin) path. +pub fn stub_config(client: xrpc.Client) -> config.Config { + stub_config_with(client, "https://resolver.test", "http://localhost:8080") +} + +pub fn stub_config_with( + client: xrpc.Client, + resolver: String, + base_url: String, +) -> config.Config { + let assert Ok(st) = store.start() + let assert Ok(ss) = sessions_memory.start() + config.new( + client:, + resolver:, + store: st, + sessions: ss, + key: keys.load(), + base_url:, + ) +} + +/// A `Context` wrapping `cfg`, with every other capability defaulted to a +/// stub that either does nothing or panics if called. Use +/// `stub_context_with` to override `catalog`, `discogs_send`, or +/// `known_users`: the parts individual handler tests actually vary. +pub fn stub_context(cfg: config.Config) -> Context { + stub_context_with( + cfg, + unreachable_catalog_deps(), + fn(_req) { Error("unused") }, + [], + ) +} + +pub fn stub_context_with( + cfg: config.Config, + catalog: catalog_deps.Deps, + discogs_send: discogs_client.Sender, + known_users: List(known_users.KnownUser), +) -> Context { + Context( + web: Web(static_directory: "", base_url: "http://localhost:8080"), + atproto: Atproto(client: cfg.client, resolver: cfg.resolver), + discogs: Discogs(auth: None, creds: cfg.sessions, send: discogs_send), + catalog:, + known_users: known_users_of(known_users), + catalog_index: empty_catalog_index(), + oauth: cfg, + ) +} + +/// A minimal `CatalogRelease`: only `title` and `created_at` are set, every +/// other field `None`. Override whichever fields a test cares about via +/// `CatalogRelease(..blank_catalog_release(), field: value)`. +pub fn blank_catalog_release() -> catalog_release.CatalogRelease { + catalog_release.CatalogRelease( + title: "Spiderland", + artist_display: None, + created_at: "2026-01-01T00:00:00Z", + external_ids: None, + released: None, + country: None, + genres: None, + styles: None, + thumb_url: None, + cover: None, + based_on: None, + credited_artists: None, + formats: None, + identifiers: None, + labels: None, + master: None, + supersedes: None, + tracklist: None, + ) +} + +/// A minimal `ShelfEntry`: an "acquired" genesis row with no subject, dated +/// 2026-01-01, every other field `None`. Override via +/// `ShelfEntry(..blank_shelf_entry(), field: value)`. +pub fn blank_shelf_entry() -> entry.ShelfEntry { + entry.ShelfEntry( + subject: None, + action: "acquired", + snapshot: None, + external_ids: None, + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + release: None, + price: None, + counterparty: None, + source: None, + created_at: "2026-01-01T00:00:00Z", + ) +} + +/// An `OauthSession` for `did:plc:me`: the fixture repeated verbatim across +/// the edit-inbox and promotion handler tests. +pub fn stub_session() -> sessions.OauthSession { + sessions.OauthSession( + did: "did:plc:me", + handle: "me.test", + pds: "https://pds.test", + issuer: "https://as.test", + token_endpoint: "https://as.test/token", + client_id: "client", + confidential: False, + dpop_key: gose.generate_ec(ec.P256), + access_token: "token", + refresh_token: "refresh", + expires_at: 0, + ) +} + +/// An `OauthSession` for `did:plc:x`, with the token fields overridable: the +/// shape shared by the oauth and discogs-scan handler tests. +pub fn stub_session_with( + access_token access_token: String, + refresh_token refresh_token: String, + expires_at expires_at: Int, +) -> sessions.OauthSession { + sessions.OauthSession( + did: "did:plc:x", + handle: "h.test", + pds: "https://pds.example", + issuer: "https://as.example", + token_endpoint: "https://as.example/token", + client_id: "cid", + confidential: False, + dpop_key: gose.generate_ec(ec.P256), + access_token:, + refresh_token:, + expires_at:, + ) +} + +pub fn field_string(body: String, path: List(String)) -> Result(String, Nil) { + json.parse(body, decode.at(path, decode.string)) |> result.replace_error(Nil) +} + +pub fn field_strings( + body: String, + path: List(String), +) -> Result(List(String), Nil) { + json.parse(body, decode.at(path, decode.list(decode.string))) + |> result.replace_error(Nil) +} + +pub fn field_nested( + body: String, + path: List(String), + inner: List(String), +) -> Result(List(String), Nil) { + json.parse( + body, + decode.at(path, decode.list(decode.at(inner, decode.string))), + ) + |> result.replace_error(Nil) +} + +pub fn field_present(body: String, path: List(String)) -> Bool { + json.parse(body, decode.at(path, decode.dynamic)) |> result.is_ok +} -- 2.51.2