//// 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 atproto/xrpc import atproto_core/xrpc as core_xrpc import crate/gen/catalog/release as catalog_release import crate/gen/shelf/entry import crate_server/catalog/source as catalog_source import crate_server/catalog_deps import crate_server/catalog_index import crate_server/context.{type Context, Atproto, Context, Discogs, Web} import crate_server/discogs_client import crate_server/follow_index import crate_server/identity_cache import crate_server/identity_resolver import crate_server/known_users import crate_server/oauth/config import crate_server/oauth/keys import crate_server/oauth/sessions import crate_server/oauth/sessions_memory import crate_server/oauth/store import crate_server/readiness import crate_server/shelf_index import crate_server/wiring import gleam/dict import gleam/dynamic/decode import gleam/erlang/process import gleam/json import gleam/list import gleam/option.{None} import gleam/result import gose import kryptos/ec /// The crate XRPC path for `rest` (method name plus optional query string), /// so the namespace lives in one place on the test side too. pub fn xrpc(rest: String) -> String { "/xrpc/dev.mokkenstorm.crate." <> rest } pub fn unreachable_client() -> xrpc.Client { xrpc.Client(send: fn(_req) { Error(core_xrpc.ConnectionFailed("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" }, ) } /// A `Resolver` over a real (empty) `identity_cache`, whose `fetch` panics: /// 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) identity_resolver.Resolver(cache:, fetch: fn(_) { panic as "identity fetch must not be called" }) } /// 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 }, count: fn() { list.length(users) }) } /// A dev-fallback-shaped `catalog_index.Store` that does nothing and reads /// back empty: the default for `stub_context_with` and any test that doesn't /// care about index state. Exported so tests needing a throwaway store (e.g. /// `promotion_test`'s write-through param) don't hand-roll one. pub fn empty_catalog_index() -> catalog_index.Store { catalog_index.Store( releases: catalog_index.ReleaseOps( upsert: fn(_) { Nil }, delete: fn(_) { Nil }, delete_for_did: fn(_) { Nil }, list: fn() { [] }, get: fn(_) { None }, find_by_barcode: fn(_) { None }, count: fn() { 0 }, ), adoptions: catalog_index.AdoptionOps( upsert: fn(_) { Nil }, delete: fn(_) { Nil }, delete_for_did: fn(_) { Nil }, count: fn(_) { 0 }, counts: fn() { dict.new() }, list: fn() { [] }, ), edits: catalog_index.EditOps( upsert: fn(_) { Nil }, delete: fn(_) { Nil }, delete_for_did: fn(_) { Nil }, for_subject: fn(_) { [] }, list_for_subjects: fn(_) { [] }, count: fn() { 0 }, ), cursor: catalog_index.CursorOps(save: fn(_) { Nil }, load: fn() { None }), ) } /// A real in-memory `shelf_index.Store`: cheap to start, and tests that /// actually exercise it can read it back directly instead of hand-rolling a /// spy, mirroring `empty_catalog_index`'s dev-fallback backend. /// A real in-memory `follow_index.Store`, same rationale as /// `fresh_shelf_index`: tests that exercise follows read it back directly. pub fn fresh_follow_index() -> follow_index.Store { let assert Ok(store) = follow_index.start() store } fn fresh_shelf_index() -> shelf_index.Store { let assert Ok(store) = shelf_index.start() store } /// Folds one `ShelfEntry` straight into `ctx.shelf_index`, mirroring what /// the jetstream consumer (or a write-through handler) would do for the /// same record -- the standard way a C3-cutover read test seeds the index /// it now reads from, instead of stubbing a fake PDS response. `uri` is the /// record's own at-uri, `rkey` its own record key; a genesis's `entry.subject` /// is `None`, an append's points back at the genesis. pub fn seed_shelf_entry( ctx: Context, did: String, uri: String, rkey: String, shelf_entry: entry.ShelfEntry, ) -> Nil { let entry_uri = case shelf_entry.subject { option.Some(ref) -> ref.uri option.None -> uri } shelf_index.record_and_fold( ctx.shelf_index, shelf_index.event_row( event_uri: uri, entry_uri:, did:, rkey:, entry: shelf_entry, ), ) } /// A `variant_source` with no candidate rows and a zero adoption count for /// every uri; use `stub_context_with_variant_source` to override it. pub fn empty_variant_source() -> catalog_source.Source { catalog_source.Source(releases: fn() { [] }, adoption_count: fn(_) { 0 }) } /// 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, identity: stub_identity_resolver(cfg.client, 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(), shelf_index: fresh_shelf_index(), follow_index: fresh_follow_index(), variant_source: empty_variant_source(), readiness: readiness.ready(), oauth: cfg, ) } /// `stub_context_with`, but also overriding `variant_source`: what the /// browse-handler resolution-strategy tests actually vary. pub fn stub_context_with_variant_source( cfg: config.Config, variant_source: catalog_source.Source, ) -> Context { Context( ..stub_context_with( cfg, unreachable_catalog_deps(), fn(_req) { Error("unused") }, [], ), variant_source:, ) } /// `stub_context_with`, but also overriding `catalog_index`: what a barcode /// index-hit test (E1) or an edit-inbox index-read test (E2) actually varies. pub fn stub_context_with_catalog_index( cfg: config.Config, discogs_send: discogs_client.Sender, known_users: List(known_users.KnownUser), catalog_index: catalog_index.Store, ) -> Context { Context( ..stub_context_with( cfg, unreachable_catalog_deps(), discogs_send, known_users, ), catalog_index:, ) } /// 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_int(body: String, path: List(String)) -> Result(Int, Nil) { json.parse(body, decode.at(path, decode.int)) |> result.replace_error(Nil) } pub fn field_bool(body: String, path: List(String)) -> Result(Bool, Nil) { json.parse(body, decode.at(path, decode.bool)) |> 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 }