diff --git a/e2e/devnet/Caddyfile b/e2e/devnet/Caddyfile index eb1b78d..4db2a9d 100644 --- a/e2e/devnet/Caddyfile +++ b/e2e/devnet/Caddyfile @@ -3,7 +3,7 @@ # internal` — no mkcert or other external tooling needed. } -alice.test, bob.test { +alice.test, bob.test, carol.test, dave.test, erin.test { tls internal reverse_proxy pds:3000 } diff --git a/e2e/devnet/create-test-accounts.sh b/e2e/devnet/create-test-accounts.sh index f0d72c5..d846c5c 100755 --- a/e2e/devnet/create-test-accounts.sh +++ b/e2e/devnet/create-test-accounts.sh @@ -17,3 +17,6 @@ create() { create alice.test create bob.test +create carol.test +create dave.test +create erin.test diff --git a/e2e/devnet/docker-compose.yml b/e2e/devnet/docker-compose.yml index 3db6757..2866c09 100644 --- a/e2e/devnet/docker-compose.yml +++ b/e2e/devnet/docker-compose.yml @@ -114,6 +114,9 @@ services: aliases: - alice.test - bob.test + - carol.test + - dave.test + - erin.test # Caddy's `tls internal` CA is generated into its own data volume; export # the root cert so slingshot (below) can be told to trust it. diff --git a/e2e/devnet/seed-social.sh b/e2e/devnet/seed-social.sh new file mode 100755 index 0000000..397517e --- /dev/null +++ b/e2e/devnet/seed-social.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash +# Seeds the devnet's fixed accounts with a follow graph, a small shared +# catalog of releases, and crate entries (grades, ratings, notes, prices, +# multi-event timelines, cover art, and deliberate feed choreography) so the +# following/followers, feed, and record-detail UI have data to render +# without touching the live network. The feed is built from ADOPTION rows: +# entries whose `release` field points at a catalog.release record, so most +# genesis entries below carry one. +# +# Not idempotent: expects a freshly wiped devnet. Every account, follow, +# release, and entry below is unconditionally created, so a second run +# duplicates the whole graph and changes the feed shape the choreography +# comments describe. +set -euo pipefail + +if ((BASH_VERSINFO[0] < 4)); then + echo "seed-social.sh: requires bash >= 4 (this script's declare -A needs it)." >&2 + echo "macOS ships bash 3.2; install a newer one (e.g. 'brew install bash') and rerun with that." >&2 + exit 1 +fi + +PDS="${PDS_URL:-http://localhost:2584}" +PASSWORD="${TEST_ACCOUNT_PASSWORD:-e2e-test-password}" + +# Every string value below is JSON-encoded through this instead of hand-quoted, +# so a title/artist/note containing a quote, backslash, or newline can never +# produce malformed JSON. python3 is already a dependency (the PNG encoder). +json_str() { + python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$1" +} + +# BSD `date -r ` reads an epoch; GNU `date -r ` stats a file of +# that name and fails, falling through to `-d @`. Trying -r first +# keeps this portable across macOS and Linux without a uname branch. +ts_offset() { + local epoch="$1" + date -u -r "$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ +} + +NOW_EPOCH="$(date -u +%s)" +T0="$(ts_offset "$NOW_EPOCH")" +T1="$(ts_offset $((NOW_EPOCH + 3600)))" +T2="$(ts_offset $((NOW_EPOCH + 7200)))" +# Well outside any converge/batch collapse window, for the one entry that +# must read as a standalone reasonSingle rather than join dave's batch. +T_PAST="$(ts_offset $((NOW_EPOCH - 90000)))" + +session() { + curl -sf "$PDS/xrpc/com.atproto.server.createSession" \ + -H 'content-type: application/json' \ + -d "{\"identifier\":$(json_str "$1"),\"password\":$(json_str "$PASSWORD")}" +} + +did_of() { echo "$1" | grep -o '"did":"[^"]*"' | head -1 | cut -d'"' -f4; } +jwt_of() { echo "$1" | grep -o '"accessJwt":"[^"]*"' | cut -d'"' -f4; } +uri_of() { echo "$1" | grep -o '"uri":"[^"]*"' | head -1 | cut -d'"' -f4; } +cid_of() { echo "$1" | grep -o '"cid":"[^"]*"' | head -1 | cut -d'"' -f4; } + +create_record() { + local jwt="$1" repo="$2" collection="$3" record="$4" + curl -sf "$PDS/xrpc/com.atproto.repo.createRecord" \ + -H "authorization: Bearer $jwt" -H 'content-type: application/json' \ + -d "{\"repo\":$(json_str "$repo"),\"collection\":$(json_str "$collection"),\"record\":$record}" +} + +follow() { + local jwt="$1" repo="$2" subject_did="$3" + create_record "$jwt" "$repo" dev.mokkenstorm.crate.graph.follow \ + "{\"\$type\":\"dev.mokkenstorm.crate.graph.follow\",\"subject\":$(json_str "$subject_did"),\"createdAt\":$(json_str "$T0")}" \ + > /dev/null +} + +# Appends `,"key":` when value is non-empty, else nothing; lets +# JSON builders below stay flat instead of branching per optional field. +opt_str() { + [[ -z "${2:-}" ]] && return 0 + printf ',"%s":%s' "$1" "$(json_str "$2")" +} + +# Same as opt_str but for values that are already JSON (ints, objects). +opt_raw() { + [[ -z "${2:-}" ]] && return 0 + printf ',"%s":%s' "$1" "$2" +} + +subject_ref() { printf '{"uri":%s,"cid":%s}' "$(json_str "$1")" "$(json_str "$2")"; } +# catalogRef and strongRef share the {uri, cid} shape we need here; named +# separately so call sites read as what they point at. +catalog_ref() { subject_ref "$1" "$2"; } + +# Writes a genesis shelf entry (no subject) and returns the raw createRecord +# response so chained callers can pull uri/cid for follow-up events. +write_genesis() { + local jwt="$1" repo="$2" action="$3" created="$4" artist="$5" title="$6" \ + media="$7" sleeve="$8" rating="$9" folder="${10}" notes="${11}" \ + counterparty="${12}" price_amount="${13}" price_currency="${14}" cover_json="${15}" \ + release_json="${16:-}" + local price_json="" + [[ -n "$price_amount" ]] && price_json="{\"amount\":$price_amount,\"currency\":$(json_str "$price_currency")}" + local snapshot_json="{\"artistDisplay\":$(json_str "$artist"),\"title\":$(json_str "$title")$(opt_raw cover "$cover_json")}" + local record + record="{\"\$type\":\"dev.mokkenstorm.crate.shelf.entry\",\"action\":$(json_str "$action"),\"createdAt\":$(json_str "$created"),\"snapshot\":$snapshot_json$(opt_str folder "$folder")$(opt_str mediaGrade "$media")$(opt_str sleeveGrade "$sleeve")$(opt_raw rating "$rating")$(opt_str notes "$notes")$(opt_str counterparty "$counterparty")$(opt_raw price "$price_json")$(opt_raw release "$release_json")}" + create_record "$jwt" "$repo" dev.mokkenstorm.crate.shelf.entry "$record" +} + +# Appends a follow-up event referencing a prior entry's strongRef. Discards +# the response: nothing here chains a third time. +write_event() { + local jwt="$1" repo="$2" action="$3" created="$4" subject_json="$5" \ + rating="${6:-}" notes="${7:-}" media="${8:-}" sleeve="${9:-}" + local record + record="{\"\$type\":\"dev.mokkenstorm.crate.shelf.entry\",\"action\":$(json_str "$action"),\"createdAt\":$(json_str "$created"),\"subject\":$subject_json$(opt_raw rating "$rating")$(opt_str notes "$notes")$(opt_str mediaGrade "$media")$(opt_str sleeveGrade "$sleeve")}" + create_record "$jwt" "$repo" dev.mokkenstorm.crate.shelf.entry "$record" > /dev/null +} + +owned_entry() { + local jwt="$1" repo="$2" artist="$3" title="$4" media="$5" sleeve="$6" \ + rating="$7" folder="$8" notes="$9" cover="${10}" release="${11:-}" + write_genesis "$jwt" "$repo" acquired "$T0" "$artist" "$title" \ + "$media" "$sleeve" "$rating" "$folder" "$notes" "" "" "" "$cover" "$release" > /dev/null +} + +wanted_entry() { + local jwt="$1" repo="$2" artist="$3" title="$4" folder="$5" release="${6:-}" + write_genesis "$jwt" "$repo" wanted "$T0" "$artist" "$title" \ + "" "" "" "$folder" "" "" "" "" "" "$release" > /dev/null +} + +# Writes an owned genesis entry then two follow-up events (rated, then +# annotated), each a bit later than the last so ordering is meaningful. +owned_with_rating_and_notes() { + local jwt="$1" repo="$2" artist="$3" title="$4" media="$5" sleeve="$6" \ + folder="$7" counterparty="$8" price_amount="$9" price_currency="${10}" \ + cover="${11}" rating="${12}" notes="${13}" release="${14:-}" + local resp uri cid subject + resp="$(write_genesis "$jwt" "$repo" acquired "$T0" "$artist" "$title" \ + "$media" "$sleeve" "" "$folder" "" "$counterparty" "$price_amount" "$price_currency" "$cover" "$release")" + uri="$(uri_of "$resp")" + cid="$(cid_of "$resp")" + subject="$(subject_ref "$uri" "$cid")" + write_event "$jwt" "$repo" rated "$T1" "$subject" "$rating" "" "" "" + write_event "$jwt" "$repo" annotated "$T2" "$subject" "" "$notes" "" "" +} + +# Writes an owned genesis entry then a regraded follow-up with updated +# grades, e.g. after a closer inspection. +owned_with_regrade() { + local jwt="$1" repo="$2" artist="$3" title="$4" media="$5" sleeve="$6" \ + folder="$7" price_amount="$8" price_currency="$9" cover="${10}" \ + new_media="${11}" new_sleeve="${12}" release="${13:-}" + local resp uri cid subject + resp="$(write_genesis "$jwt" "$repo" acquired "$T0" "$artist" "$title" \ + "$media" "$sleeve" "" "$folder" "" "" "$price_amount" "$price_currency" "$cover" "$release")" + uri="$(uri_of "$resp")" + cid="$(cid_of "$resp")" + subject="$(subject_ref "$uri" "$cid")" + write_event "$jwt" "$repo" regraded "$T1" "$subject" "" "" "$new_media" "$new_sleeve" +} + +# Mints a shared-catalog release. The lexicon calls this "lazy promotion on +# first reference", so minting right before the entries that adopt it +# matches how the real flow orders things. +mint_release() { + local jwt="$1" repo="$2" artist="$3" title="$4" year="$5" genre="$6" + local resp + resp="$(create_record "$jwt" "$repo" dev.mokkenstorm.crate.catalog.release \ + "{\"\$type\":\"dev.mokkenstorm.crate.catalog.release\",\"title\":$(json_str "$title"),\"createdAt\":$(json_str "$T0"),\"artistDisplay\":$(json_str "$artist"),\"released\":$(json_str "$year"),\"genres\":[$(json_str "$genre")]}")" + catalog_ref "$(uri_of "$resp")" "$(cid_of "$resp")" +} + +# Hand-rolled 300x300 solid PNG (stdlib struct+zlib only, no Pillow) so each +# account gets a distinct cover color without shipping binary fixtures. +generate_cover() { + local out="$1" r="$2" g="$3" b="$4" + python3 - "$out" "$r" "$g" "$b" 300 <<'PY' +import struct +import sys +import zlib + +path, r, g, b, size = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]) + + +def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + +sig = b"\x89PNG\r\n\x1a\n" +ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) +row = bytes([0]) + bytes([r, g, b]) * size +raw = row * size +idat = zlib.compress(raw, 9) + +with open(path, "wb") as f: + f.write(sig) + f.write(chunk(b"IHDR", ihdr)) + f.write(chunk(b"IDAT", idat)) + f.write(chunk(b"IEND", b"")) +PY +} + +# Best-effort: generates a cover, uploads it, and echoes the blob JSON +# fragment verbatim (per com.atproto.repo.uploadBlob's `{blob: {$type, ref: +# {$link}, mimeType, size}}` shape). Warns and returns empty on any failure +# instead of aborting the whole seed run. +make_cover_blob() { + local handle="$1" jwt="$2" r="$3" g="$4" b="$5" + local png resp blob_json + # `mktemp XXXXXX.png` is not a template on BSD/macOS mktemp (the X's + # must be trailing); it would return that literal path. `-t` picks a temp + # dir and suffixes a real random name; the upload sets content-type + # explicitly, so the .png extension was never load-bearing. + png="$(mktemp -t crate-cover)" + if ! generate_cover "$png" "$r" "$g" "$b" 2>/dev/null; then + echo "warning: cover generation failed for $handle, skipping cover" >&2 + rm -f "$png" + return 0 + fi + resp="$(curl -sf "$PDS/xrpc/com.atproto.repo.uploadBlob" \ + -H "authorization: Bearer $jwt" -H 'content-type: image/png' \ + --data-binary "@$png" 2>/dev/null || true)" + rm -f "$png" + if blob_json="$(printf '%s' "$resp" | + python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin)["blob"]))' 2>/dev/null)"; then + echo "$blob_json" + return 0 + fi + echo "warning: cover upload failed for $handle, skipping cover" >&2 +} + +declare -A DID JWT COVER RELEASE +for handle in alice.test bob.test carol.test dave.test erin.test; do + s="$(session "$handle")" + DID[$handle]="$(did_of "$s")" + JWT[$handle]="$(jwt_of "$s")" + echo "session $handle -> ${DID[$handle]}" +done + +# Asymmetric graph for varied counts: alice is popular, dave follows all, +# erin only has followers. +follow "${JWT[alice.test]}" "${DID[alice.test]}" "${DID[bob.test]}" +follow "${JWT[alice.test]}" "${DID[alice.test]}" "${DID[carol.test]}" +follow "${JWT[bob.test]}" "${DID[bob.test]}" "${DID[alice.test]}" +follow "${JWT[bob.test]}" "${DID[bob.test]}" "${DID[carol.test]}" +follow "${JWT[carol.test]}" "${DID[carol.test]}" "${DID[bob.test]}" +follow "${JWT[carol.test]}" "${DID[carol.test]}" "${DID[alice.test]}" +follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[alice.test]}" +follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[bob.test]}" +follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[carol.test]}" +follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[erin.test]}" +echo "follow graph seeded" + +COVER[alice.test]="$(make_cover_blob alice.test "${JWT[alice.test]}" 196 62 44)" +COVER[bob.test]="$(make_cover_blob bob.test "${JWT[bob.test]}" 44 110 196)" +COVER[carol.test]="$(make_cover_blob carol.test "${JWT[carol.test]}" 68 163 91)" +COVER[dave.test]="$(make_cover_blob dave.test "${JWT[dave.test]}" 196 154 44)" +COVER[erin.test]="$(make_cover_blob erin.test "${JWT[erin.test]}" 140 68 196)" +echo "cover art seeded" + +# Small shared catalog: alice mints most of it, bob mints a couple, matching +# how a real lazy-promotion catalog fills in from whoever gets there first. +RELEASE[R1]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "Alice Coltrane" "Journey in Satchidananda" 1971 jazz)" +RELEASE[R2]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "Pharoah Sanders" "Karma" 1969 jazz)" +RELEASE[R3]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "Can" "Tago Mago" 1971 krautrock)" +RELEASE[R4]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "J Dilla" "Donuts" 2006 hiphop)" +RELEASE[R5]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "Stereolab" "Dots and Loops" 1997 electronic)" +RELEASE[R6]="$(mint_release "${JWT[alice.test]}" "${DID[alice.test]}" "Arthur Russell" "Calling Out of Context" 2004 ambient)" +RELEASE[R7]="$(mint_release "${JWT[bob.test]}" "${DID[bob.test]}" "Faust" "Faust IV" 1973 krautrock)" +RELEASE[R8]="$(mint_release "${JWT[bob.test]}" "${DID[bob.test]}" "Broadcast" "Tender Buttons" 2005 electronic)" +echo "catalog releases minted" + +# alice: jazz. Two chained timelines (rated+annotated, then regraded), one +# plain owned entry, and a couple of wanted entries. Most owned/wanted rows +# adopt a catalog release; the plain owned entry and one wanted stay +# snapshot-only so that state is still represented. +owned_with_rating_and_notes "${JWT[alice.test]}" "${DID[alice.test]}" \ + "Alice Coltrane" "Journey in Satchidananda" NM VG+ jazz "Bleecker Street Records" 4500 USD \ + "${COVER[alice.test]}" 5 "Found at a stoop sale; the dead wax is pristine." "${RELEASE[R1]}" +owned_with_regrade "${JWT[alice.test]}" "${DID[alice.test]}" \ + "Pharoah Sanders" "Karma" VG+ VG jazz 3200 USD "${COVER[alice.test]}" VG VG "${RELEASE[R2]}" +owned_entry "${JWT[alice.test]}" "${DID[alice.test]}" \ + "John Coltrane" "A Love Supreme" NM NM 5 jazz "Repress, but it hits." "${COVER[alice.test]}" +wanted_entry "${JWT[alice.test]}" "${DID[alice.test]}" "Dorothy Ashby" "Afro-Harping" jazz +wanted_entry "${JWT[alice.test]}" "${DID[alice.test]}" "Sun Ra" "Space Is the Place" jazz "${RELEASE[R6]}" + +# bob: krautrock. +owned_with_rating_and_notes "${JWT[bob.test]}" "${DID[bob.test]}" \ + "Can" "Tago Mago" NM NM krautrock "Record fair, Cologne" 5500 EUR \ + "${COVER[bob.test]}" 5 "Side two still turns heads at parties." "${RELEASE[R3]}" +owned_with_regrade "${JWT[bob.test]}" "${DID[bob.test]}" \ + "Faust" "Faust IV" VG VG+ krautrock "" "" "${COVER[bob.test]}" VG+ VG "${RELEASE[R7]}" +owned_entry "${JWT[bob.test]}" "${DID[bob.test]}" \ + "Cluster" "Zuckerzeit" G+ G 3 krautrock "Beat to hell but plays fine." "${COVER[bob.test]}" +wanted_entry "${JWT[bob.test]}" "${DID[bob.test]}" "Neu!" "Neu! 75" krautrock +wanted_entry "${JWT[bob.test]}" "${DID[bob.test]}" "Harmonia" "Deluxe" krautrock "${RELEASE[R8]}" + +# carol: electronic/pop, plus a subjectConverge entry (see below). +owned_with_rating_and_notes "${JWT[carol.test]}" "${DID[carol.test]}" \ + "Stereolab" "Dots and Loops" NM NM electronic "" 3800 EUR \ + "${COVER[carol.test]}" 5 "Pulled this straight to the top of the pile." "${RELEASE[R5]}" +owned_with_regrade "${JWT[carol.test]}" "${DID[carol.test]}" \ + "Broadcast" "Tender Buttons" VG+ VG electronic "" "" "${COVER[carol.test]}" VG+ VG+ "${RELEASE[R8]}" +owned_entry "${JWT[carol.test]}" "${DID[carol.test]}" \ + "Laetitia Sadier" "Onetwothreefour" VG+ VG+ 4 electronic "Solo record, still slept on." "${COVER[carol.test]}" +wanted_entry "${JWT[carol.test]}" "${DID[carol.test]}" "Broadcast" "Haha Sound" electronic +wanted_entry "${JWT[carol.test]}" "${DID[carol.test]}" "Add N to X" "Add Insult to Injury" electronic "${RELEASE[R7]}" +# subjectConverge: bob (above) and carol both adopt Tago Mago within the T0 +# window, on two distinct dids, so the feed should collapse them together. +owned_entry "${JWT[carol.test]}" "${DID[carol.test]}" \ + "Can" "Tago Mago" NM VG+ 4 krautrock "Bought on a whim after bob wouldn't stop talking about it." \ + "${COVER[carol.test]}" "${RELEASE[R3]}" + +# dave: hip-hop, plus an actorBatch burst and an isolated single (see below). +owned_with_rating_and_notes "${JWT[dave.test]}" "${DID[dave.test]}" \ + "J Dilla" "Donuts" NM NM hiphop "Discogs seller" 6000 USD \ + "${COVER[dave.test]}" 5 "Played it front to back the day it arrived." "${RELEASE[R4]}" +owned_with_regrade "${JWT[dave.test]}" "${DID[dave.test]}" \ + "Madlib" "Shades of Blue" VG+ VG hiphop "" "" "${COVER[dave.test]}" VG+ VG+ +owned_entry "${JWT[dave.test]}" "${DID[dave.test]}" \ + "MF DOOM" "Operation: Doomsday" VG VG 5 hiphop "Original press, crackly but worth it." "${COVER[dave.test]}" +wanted_entry "${JWT[dave.test]}" "${DID[dave.test]}" "MF DOOM" "Madvillainy" hiphop "${RELEASE[R2]}" +wanted_entry "${JWT[dave.test]}" "${DID[dave.test]}" "Flying Lotus" "Cosmogramma" hiphop "${RELEASE[R8]}" +# actorBatch: feed_skeleton.collapse runs the subjectConverge pass first, so +# any release a co-adopter also touches in the T0 window is swept away from +# dave before the actor pass ever sees it -- the Madvillainy (R2) and +# Cosmogramma (R8) rows above are exactly that: alice also adopts R2 and +# bob/carol/erin also adopt R8, so both converge instead of batching. R9 and +# R10 are minted here and adopted only by dave, so together with Donuts (R4, +# also dave-exclusive) they give dave exactly 3 same-did rows in the T0 +# window: >= actor_batch_threshold (2) and < import_count_threshold (5), +# which is what actually collapses to one actorBatch card. +RELEASE[R9]="$(mint_release "${JWT[dave.test]}" "${DID[dave.test]}" "Quasimoto" "The Unseen" 2000 hiphop)" +RELEASE[R10]="$(mint_release "${JWT[dave.test]}" "${DID[dave.test]}" "Danny Brown" "XXX" 2011 hiphop)" +owned_entry "${JWT[dave.test]}" "${DID[dave.test]}" \ + "Quasimoto" "The Unseen" VG+ VG 4 hiphop "" "${COVER[dave.test]}" "${RELEASE[R9]}" +owned_entry "${JWT[dave.test]}" "${DID[dave.test]}" \ + "Danny Brown" "XXX" NM VG+ 4 hiphop "" "${COVER[dave.test]}" "${RELEASE[R10]}" +# reasonSingle: a crate-digging trip well before the T0 batch window. Every +# did's leftover rows get re-clustered on their own timeline, and T_PAST sits +# more than 6h from the R4/R9/R10 trio, so this opens a fresh window of one +# and reads as dave's own standalone entry rather than folding into the batch. +write_genesis "${JWT[dave.test]}" "${DID[dave.test]}" acquired "$T_PAST" \ + "Arthur Russell" "Calling Out of Context" VG VG "" ambient "" "" "" "" \ + "${COVER[dave.test]}" "${RELEASE[R6]}" > /dev/null + +# erin: ambient, plus a reasonImport burst (see below). +owned_with_rating_and_notes "${JWT[erin.test]}" "${DID[erin.test]}" \ + "Arthur Russell" "Calling Out of Context" NM NM ambient "" 4200 USD \ + "${COVER[erin.test]}" 5 "Warmer and stranger every time it spins." "${RELEASE[R6]}" +owned_with_regrade "${JWT[erin.test]}" "${DID[erin.test]}" \ + "Popol Vuh" "Hosianna Mantra" VG+ VG ambient "" "" "${COVER[erin.test]}" VG+ VG+ +owned_entry "${JWT[erin.test]}" "${DID[erin.test]}" \ + "Julius Eastman" "Femenine" VG VG 4 ambient "Reissue, still gorgeous." "${COVER[erin.test]}" "${RELEASE[R8]}" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Arthur Russell" "World of Echo" ambient +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Harold Budd & Brian Eno" "Ambient 2: The Plateaux of Mirror" ambient +# reasonImport: five wanted rows in the same T0 window, on releases minted +# right here and adopted only by erin (R11-R15). Reusing the shared catalog +# (R1-R8) here would let subjectConverge steal these rows the same way it +# steals dave's above, since alice/bob/carol/dave already touch those +# releases in this window; dedicated releases mean nothing converges them +# away. That leaves erin with 5 same-did rows in one window, clearing +# import_count_threshold (5) regardless of actor_batch_threshold (2), so the +# feed collapses them into one reasonImport card, shaped like a bulk import. +RELEASE[R11]="$(mint_release "${JWT[erin.test]}" "${DID[erin.test]}" "Brian Eno" "Ambient 1: Music for Airports" 1978 ambient)" +RELEASE[R12]="$(mint_release "${JWT[erin.test]}" "${DID[erin.test]}" "Grouper" "Ruins" 2014 ambient)" +RELEASE[R13]="$(mint_release "${JWT[erin.test]}" "${DID[erin.test]}" "William Basinski" "The Disintegration Loops" 2002 ambient)" +RELEASE[R14]="$(mint_release "${JWT[erin.test]}" "${DID[erin.test]}" "Stars of the Lid" "The Tired Sounds of Stars of the Lid" 2001 ambient)" +RELEASE[R15]="$(mint_release "${JWT[erin.test]}" "${DID[erin.test]}" "Tim Hecker" "Ravedeath, 1972" 2011 ambient)" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Brian Eno" "Ambient 1: Music for Airports" ambient "${RELEASE[R11]}" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Grouper" "Ruins" ambient "${RELEASE[R12]}" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "William Basinski" "The Disintegration Loops" ambient "${RELEASE[R13]}" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Stars of the Lid" "The Tired Sounds of Stars of the Lid" ambient "${RELEASE[R14]}" +wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Tim Hecker" "Ravedeath, 1972" ambient "${RELEASE[R15]}" + +echo "crate entries seeded" diff --git a/server/test/feed_skeleton_test.gleam b/server/test/feed_skeleton_test.gleam index 58f86c5..f0d0db5 100644 --- a/server/test/feed_skeleton_test.gleam +++ b/server/test/feed_skeleton_test.gleam @@ -12,6 +12,7 @@ import at_record_server/feed_skeleton import gleam/int import gleam/list import gleam/option.{None, Some} +import gleam/string const release_a = "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/ra" @@ -283,3 +284,127 @@ pub fn ordering_by_recency_is_preserved_for_unrelated_singles_test() { }) assert dids == ["did:plc:c", "did:plc:b", "did:plc:a"] } + +fn devnet_release_uri(n: Int) -> String { + "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r" <> int.to_string(n) +} + +fn devnet_row( + did: String, + rkey: String, + release_n: Int, + status: String, + created_at: String, +) -> Adoption { + row( + did:, + rkey:, + release_uri: devnet_release_uri(release_n), + status:, + created_at:, + source: None, + ) +} + +/// Mirrors e2e/devnet/seed-social.sh's adoption shape exactly: same dids, +/// same release ids, same T0/T_PAST timestamps. Only a genesis shelf.entry +/// (one that carries a `release` ref) becomes an adoption row -- follow-up +/// rated/annotated/regraded events reference their entry via `subject` +/// instead, so the T1/T2 timestamps the seed also stamps never show up +/// here. This is the highest-value check on the seed's own choreography +/// claims: it makes them true without spinning up a devnet. +pub fn devnet_seed_choreography_produces_the_claimed_reason_mix_test() { + let t0 = "2026-01-01T00:00:00Z" + let t_past = "2025-12-30T23:00:00Z" + let rows = [ + // alice: jazz genesis rows carrying a release (R1, R2, R6). + devnet_row("did:plc:alice", "a1", 1, "acquired", t0), + devnet_row("did:plc:alice", "a2", 2, "acquired", t0), + devnet_row("did:plc:alice", "a3", 6, "wanted", t0), + // bob: krautrock rows (R3, R7, R8). + devnet_row("did:plc:bob", "b1", 3, "acquired", t0), + devnet_row("did:plc:bob", "b2", 7, "acquired", t0), + devnet_row("did:plc:bob", "b3", 8, "wanted", t0), + // carol: electronic rows (R5, R8, R7) plus the deliberate R3 converge pair. + devnet_row("did:plc:carol", "c1", 5, "acquired", t0), + devnet_row("did:plc:carol", "c2", 8, "acquired", t0), + devnet_row("did:plc:carol", "c3", 7, "wanted", t0), + devnet_row("did:plc:carol", "c4", 3, "acquired", t0), + // dave: R4 (dave-exclusive), R2/R8 (shared -- converge away), R9/R10 + // (dave-exclusive -- actorBatch), R6 at T_PAST (reasonSingle). + devnet_row("did:plc:dave", "d1", 4, "acquired", t0), + devnet_row("did:plc:dave", "d2", 2, "wanted", t0), + devnet_row("did:plc:dave", "d3", 8, "wanted", t0), + devnet_row("did:plc:dave", "d4", 9, "acquired", t0), + devnet_row("did:plc:dave", "d5", 10, "acquired", t0), + devnet_row("did:plc:dave", "d6", 6, "acquired", t_past), + // erin: R6/R8 (shared -- converge away), R11-R15 (erin-exclusive -- reasonImport). + devnet_row("did:plc:erin", "e1", 6, "acquired", t0), + devnet_row("did:plc:erin", "e2", 8, "acquired", t0), + devnet_row("did:plc:erin", "e3", 11, "wanted", t0), + devnet_row("did:plc:erin", "e4", 12, "wanted", t0), + devnet_row("did:plc:erin", "e5", 13, "wanted", t0), + devnet_row("did:plc:erin", "e6", 14, "wanted", t0), + devnet_row("did:plc:erin", "e7", 15, "wanted", t0), + ] + + let items = feed_skeleton.collapse(rows) + + let converge = + list.filter(items, fn(item) { + case item.reason { + FeedItemReasonReasonSubjectConverge(_) -> True + _ -> False + } + }) + let actor_batches = + list.filter(items, fn(item) { + case item.reason { + FeedItemReasonReasonActorBatch(_) -> True + _ -> False + } + }) + let imports = + list.filter(items, fn(item) { + case item.reason { + FeedItemReasonReasonImport(_) -> True + _ -> False + } + }) + let singles = + list.filter(items, fn(item) { + case item.reason { + FeedItemReasonReasonSingle(_) -> True + _ -> False + } + }) + + // R2 (alice+dave), R3 (bob+carol), R6@T0 (alice+erin), R7 (bob+carol), and + // R8 (bob+carol+dave+erin) converge: five genuine groups survive. + assert list.length(converge) == 5 + + // Donuts (R4) plus the two dedicated pressings (R9, R10) are the only + // releases nobody else touches in dave's T0 window: exactly one + // actorBatch card, 3 rows (>= actor_batch_threshold, < import_count_threshold). + let assert [actor_batch] = actor_batches + assert list.all(actor_batch.entries, fn(entry) { + entry.actor == "did:plc:dave" + }) + assert list.map(actor_batch.entries, fn(entry) { entry.entry_id }) + |> list.sort(string.compare) + == ["d1", "d4", "d5"] + + // erin's five dedicated wanted rows (R11-R15) clear the import floor. + let assert [import_item] = imports + assert list.all(import_item.entries, fn(entry) { + entry.actor == "did:plc:erin" + }) + assert list.length(import_item.entries) == 5 + + // alice (R1), carol (R5), and dave's T_PAST row (R6) all stay standalone. + assert list.length(singles) == 3 + assert list.any(singles, fn(item) { + item.entries + == [get_feed_skeleton.EntryRef(actor: "did:plc:dave", entry_id: "d6")] + }) +}