{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[7964b28f] fix\n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -923,6 +923,15 @@ version = \"0.2.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\"\n \n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -980,6 +989,36 @@ version = \"6.0.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf\"\n \n+[[package]]\n+name = \"rand\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n+dependencies = [\n+ \"libc\",\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.6.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\"\n+dependencies = [\n+ \"getrandom 0.2.17\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1270,6 +1309,7 @@ dependencies = [\n \"dotenvy\",\n \"futures-util\",\n \"maud\",\n+ \"rand\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1280,6 +1320,7 @@ dependencies = [\n \"tower-http 0.5.2\",\n \"tracing\",\n \"tracing-subscriber\",\n+ \"urlencoding\",\n ]\n \n [[package]]\n@@ -1656,6 +1697,12 @@ dependencies = [\n \"serde\",\n ]\n \n+[[package]]\n+name = \"urlencoding\"\n+version = \"2.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2052,6 +2099,26 @@ dependencies = [\n \"synstructure\",\n ]\n \n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"zerofrom\"\n version = \"0.1.8\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -20,6 +20,8 @@ reqwest = { version = \"0.12\", features = [\"json\"] }\n dotenvy = \"0.15\"\n async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n+rand = \"0.8\"\n+urlencoding = \"2\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -47,6 +47,7 @@ pub async fn post_ui_html(\n ratio_left,\n ratio_right,\n scope,\n+ next,\n } => {\n let parent = parent_from_scope(&scope);\n if let Err(e) = state\n@@ -56,9 +57,19 @@ pub async fn post_ui_html(\n return ui_js_warn(&e).into_response();\n }\n let tree = state.tree.read().await;\n+ if !next.trim().is_empty() {\n+ drop(tree);\n+ return JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(next.trim())\n+ ))\n+ .into_response();\n+ }\n let empty = crate::reducer::NodeState::default();\n let node = tree.get(&parent).unwrap_or(&empty);\n let panel = ranking_panel(&parent, node, &tree);\n+ drop(tree);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n@@ -126,6 +137,7 @@ mod tests {\n ratio_left: 3,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/src/form_template.rs b/server/src/form_template.rs\nindex b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644\n--- a/server/src/form_template.rs\n+++ b/server/src/form_template.rs\n@@ -7,19 +7,30 @@ pub fn template_json_compact(v: &T) -> serde_json::Result\n serde_json::to_string(v)\n }\n \n-/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n-/// string for `key` (empty if missing). Other keys are unchanged.\n+/// Recursively walk the JSON AST and replace form holes with submitted values.\n+///\n+/// - `{\"$form\": \"key\"}` → string (empty if missing)\n+/// - `{\"$form:i32\": \"key\"}` → JSON number (0 if missing or unparseable)\n pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n match val {\n Value::Object(map) => {\n if map.len() == 1 {\n- if let Some(Value::String(field_name)) = map.get(\"$form\") {\n- let submitted = form_data\n- .get(field_name.as_str())\n- .map(|s| s.as_str())\n- .unwrap_or(\"\");\n- *val = Value::String(submitted.to_string());\n- return;\n+ if let Some((hole_key, Value::String(field_name))) = map.iter().next() {\n+ if let Some(form_type) = hole_key.strip_prefix(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = match form_type {\n+ \"\" => Value::String(submitted.to_string()),\n+ \":i32\" => {\n+ let n: i32 = submitted.trim().parse().unwrap_or(0);\n+ Value::Number(n.into())\n+ }\n+ _ => Value::String(submitted.to_string()),\n+ };\n+ return;\n+ }\n }\n }\n for v in map.values_mut() {\n@@ -62,6 +73,45 @@ mod tests {\n text: String,\n }\n \n+ #[test]\n+ fn i32_holes_become_numbers() {\n+ let json = r#\"{\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"ratio_left\".into(), \"75\".into());\n+ form.insert(\"ratio_right\".into(), \"25\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"ratio_left\"], 75);\n+ assert_eq!(v[\"ratio_right\"], 25);\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Ratios {\n+ ratio_left: i32,\n+ ratio_right: i32,\n+ }\n+ let r: Ratios = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ r,\n+ Ratios {\n+ ratio_left: 75,\n+ ratio_right: 25,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn i32_hole_missing_or_bad_defaults_to_zero() {\n+ let json = r#\"{\"n\": {\"$form:i32\": \"missing\"}}\"#;\n+ let v = fill_template_from_form(json, &HashMap::new()).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ let mut form = HashMap::new();\n+ form.insert(\"missing\".into(), \"nope\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ }\n+\n #[test]\n fn holes_become_strings() {\n let json = r#\"{\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,8 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod vote;\n+\n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\n const SORTER_UI_JS: &str = include_str!(\"../../static/sorter_ui.js\");\n \n@@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup {\n }\n }\n \n-fn item_href(id: &ItemId) -> String {\n+pub(crate) fn item_href(id: &ItemId) -> String {\n id.browse_href()\n }\n \n@@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n let empty_node = NodeState::default();\n let node = tree.get(&item).unwrap_or(&empty_node);\n \n+ let child_count = node.children.len();\n+ let vote_link = if child_count >= 2 {\n+ Some(vote::vote_href(&item))\n+ } else {\n+ None\n+ };\n+\n let body = html! {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n (entity_section(&item, node, false))\n+ @if let Some(href) = vote_link {\n+ p class=\"vote-cta\" {\n+ a class=\"btn-primary\" href=(href) data-testid=\"vote-children\" { \"Vote on children\" }\n+ }\n+ }\n (ranking_panel(&item, node, &tree))\n };\n layout(\"sorter2\", body, views)\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,6 +4,7 @@ pub mod events;\n pub mod fetch;\n pub mod form_template;\n pub mod html;\n+pub mod pair;\n pub mod parser;\n pub mod path_types;\n pub mod ranking;\n@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/static/:filename\", get(crate::html::serve_static))\n .route(\"/~/*item_path\", get(crate::html::browse))\n .route(\"/\", get(crate::html::home))\n+ .route(\"/vote\", get(crate::html::vote::vote_page))\n .route(\"/ui\", post(crate::api::ui_html::post_ui_html))\n .with_state(state)\n .layer(TraceLayer::new_for_http())\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -31,6 +31,9 @@ pub enum HtmlUiAction {\n /// Parent node [`ItemId`] string; empty = tree root.\n #[serde(default)]\n scope: String,\n+ /// After vote, navigate here (vote compare page).\n+ #[serde(default)]\n+ next: String,\n },\n /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n@@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form(\n mod tests {\n use super::*;\n \n+ #[test]\n+ fn record_vote_round_trip_with_typed_ratio_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"record_vote\",\n+ \"a\": \"x\",\n+ \"b\": \"y\",\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"},\n+ \"scope\": \"parent\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ form.insert(\"ratio_left\".into(), \"60\".into());\n+ form.insert(\"ratio_right\".into(), \"40\".into());\n+ assert_eq!(\n+ parse_html_ui_from_form(&form).unwrap(),\n+ HtmlUiAction::RecordVote {\n+ a: \"x\".into(),\n+ b: \"y\".into(),\n+ ratio_left: 60,\n+ ratio_right: 40,\n+ scope: \"parent\".into(),\n+ next: String::new(),\n+ }\n+ );\n+ }\n+\n #[test]\n fn record_vote_round_trip_with_form_holes() {\n let template = serde_json::json!({\n@@ -96,6 +129,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: \"amitheasshole\".into(),\n+ next: String::new(),\n }\n );\n }\n@@ -122,6 +156,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 021918f8e778b84e4b3eac8559fab7d223a3d490..3c2cec5cb5094013387147c621d75bd341bc06cc 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -224,4 +224,148 @@ code {\n \n h1 {\n margin: 0;\n+}\n+\n+.vote-cta {\n+ margin: 0.75rem 0;\n+}\n+\n+.vote-compare-shell {\n+ max-width: 960px;\n+}\n+\n+.vote-compare-scope {\n+ margin: 0.5rem 0 1rem;\n+}\n+\n+.vote-compare-pair {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 1rem;\n+ align-items: start;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-vs {\n+ align-self: center;\n+ font-size: 1.25rem;\n+ color: var(--muted);\n+ font-weight: 600;\n+}\n+\n+.vote-compare-side {\n+ background: var(--panel);\n+ border: 1px solid var(--border);\n+ border-radius: 8px;\n+ padding: 1rem;\n+ min-height: 120px;\n+}\n+\n+.vote-compare-item {\n+ color: var(--accent);\n+ text-decoration: none;\n+ display: block;\n+}\n+\n+.vote-compare-item:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-figure {\n+ margin: 0.75rem 0 0;\n+}\n+\n+.vote-compare-image {\n+ display: block;\n+ max-width: 100%;\n+ height: auto;\n+ border-radius: 6px;\n+ border: 1px solid var(--border);\n+}\n+\n+.vote-compare-item-body {\n+ margin-top: 0.75rem;\n+ font-size: 0.9rem;\n+}\n+\n+.vote-compare-nav {\n+ display: flex;\n+ justify-content: space-between;\n+ align-items: center;\n+ gap: 1rem;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-next {\n+ color: var(--accent);\n+ text-decoration: none;\n+}\n+\n+.vote-compare-next.is-disabled {\n+ color: var(--muted);\n+}\n+\n+.vote-compare-back {\n+ text-decoration: none;\n+}\n+\n+.vote-compare-back:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-slider-label {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 0.75rem;\n+ align-items: center;\n+ margin: 1.25rem 0;\n+}\n+\n+.vote-compare-slider-label input[type=\"range\"] {\n+ width: 100%;\n+ min-width: 160px;\n+}\n+\n+#vote-slider-left-label {\n+ text-align: right;\n+}\n+\n+#vote-slider-right-label {\n+ text-align: left;\n+}\n+\n+.vote-edge-history-title {\n+ margin: 1.5rem 0 0.5rem;\n+ font-size: 1rem;\n+}\n+\n+.vote-edge-history {\n+ list-style: none;\n+ padding: 0;\n+ margin: 0;\n+}\n+\n+.vote-edge-history-row {\n+ margin-bottom: 0.75rem;\n+}\n+\n+.vote-edge-meta {\n+ font-size: 0.875rem;\n+ margin-bottom: 0.25rem;\n+}\n+\n+.ratio-bar {\n+ display: flex;\n+ height: 8px;\n+ border-radius: 4px;\n+ overflow: hidden;\n+ background: var(--border);\n+}\n+\n+.ratio-left {\n+ background: var(--accent);\n+}\n+\n+.ratio-right {\n+ background: var(--muted);\n }\n\\ No newline at end of file\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 62c9158ecb3c2140b83efd2b27ea706a9e0ae127..c8d7c3ef2413399ced7898a7edb3e7a2c8e16163 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -81,7 +81,22 @@\n });\n }\n \n+ function initVoteSlider() {\n+ var slider = document.getElementById('vote-preference-slider');\n+ if (!slider) return;\n+ var leftInput = document.getElementById('vote-ratio-left');\n+ var rightInput = document.getElementById('vote-ratio-right');\n+ function update() {\n+ var v = parseInt(slider.value, 10);\n+ if (leftInput) leftInput.value = String(v);\n+ if (rightInput) rightInput.value = String(100 - v);\n+ }\n+ slider.addEventListener('input', update);\n+ update();\n+ }\n+\n function initSorterUi() {\n+ initVoteSlider();\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n@@ -89,10 +104,11 @@\n if (f.getAttribute('data-navigate') === 'full') return;\n e.preventDefault();\n await postUiForm(f);\n- if (f.id === 'vote-form') {\n+ if (f.id === 'vote-form' || f.id === 'vote-compare-form') {\n f.reset();\n- var firstField = f.querySelector('input[type=\"text\"]');\n- if (firstField) firstField.focus();\n+ var slider = f.querySelector('#vote-preference-slider');\n+ if (slider) slider.value = '50';\n+ initVoteSlider();\n }\n });\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[b32c92f2] consolidated shared functions in tests (composer 2 fast)\n\nSide B — unified diff (full patch):\ndiff --git a/test/auth.bb b/test/auth.bb\nindex a67cc1de763434176d2f68e419dd37a8cd328395..78cffc875ffe83f512bca253f4a7489227311da4 100644\n--- a/test/auth.bb\n+++ b/test/auth.bb\n@@ -3,113 +3,14 @@\n (:require [babashka.process :as p]\n [babashka.fs :as fs]\n [cheshire.core :as json]\n- [org.httpkit.server :as http]\n- [test.common :as common]))\n+ [clojure.string :as str]\n+ [test.common :as common]\n+ [test.oauth :as oauth]))\n \n-(def ^:private ansi-green \"\\033[32m\")\n-(def ^:private ansi-red \"\\033[31m\")\n-(def ^:private ansi-reset \"\\033[0m\")\n (def ^:private counts (atom {:pass 0 :fail 0}))\n \n-(defn- pass [msg]\n- (swap! counts update :pass inc)\n- (println (str ansi-green \" ✓ \" ansi-reset msg)))\n-\n-(defn- fail [msg]\n- (swap! counts update :fail inc)\n- (println (str ansi-red \" ✗ \" ansi-reset msg)))\n-\n (defn- assert! [pred msg]\n- (if pred\n- (pass msg)\n- (do (fail msg)\n- (throw (ex-info (str \"FAIL: \" msg) {})))))\n-\n-(defn- http-client []\n- (-> (java.net.http.HttpClient/newBuilder)\n- (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)\n- (.build)))\n-\n-(defn- http-get [url & {:keys [headers]}]\n- (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (doseq [[k v] (or headers {})]\n- (.header b k v))\n- (let [req (-> b (.GET) (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n-\n-(defn- http-post-json [url data & {:keys [headers]}]\n- (let [body (json/generate-string data)\n- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (.header b \"Content-Type\" \"application/json\")\n- (doseq [[k v] (or headers {})]\n- (.header b k v))\n- (let [req (-> b\n- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))\n- (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n-\n-(defn- http-post-form [url form]\n- (let [pairs (->> form\n- (map (fn [[k v]]\n- (str (java.net.URLEncoder/encode (name k) \"UTF-8\")\n- \"=\"\n- (java.net.URLEncoder/encode (str v) \"UTF-8\"))))\n- (clojure.string/join \"&\"))\n- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (.header b \"Content-Type\" \"application/x-www-form-urlencoded\")\n- (let [req (-> b\n- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))\n- (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n-\n-(defn- parse-query [s]\n- (into {}\n- (for [part (clojure.string/split (or s \"\") #\"&\")\n- :when (not (clojure.string/blank? part))]\n- (let [[k v] (clojure.string/split part #\"=\" 2)]\n- [(keyword (java.net.URLDecoder/decode k \"UTF-8\"))\n- (some-> v (java.net.URLDecoder/decode \"UTF-8\"))]))))\n-\n-(defn- b64url [s]\n- (-> (java.util.Base64/getUrlEncoder)\n- (.withoutPadding)\n- (.encodeToString (.getBytes s \"UTF-8\"))))\n-\n-(defn- make-id-token [sub]\n- (str (b64url \"{\\\"alg\\\":\\\"RS256\\\",\\\"typ\\\":\\\"JWT\\\"}\")\n- \".\"\n- (b64url (json/generate-string {:sub sub}))\n- \".fakesig\"))\n-\n-(defn- start-mock-google [port]\n- (let [google-users [\"google-user-1\" \"google-user-2\"]\n- !call-count (atom 0)\n- handler\n- (fn [req]\n- (cond\n- (and (= :get (:request-method req))\n- (= \"/o/oauth2/v2/auth\" (:uri req)))\n- (let [q (parse-query (:query-string req))\n- redirect-uri (:redirect_uri q)\n- state (:state q)\n- loc (str redirect-uri \"?code=mockcode&state=\" state)]\n- {:status 302 :headers {\"Location\" loc} :body \"\"})\n-\n- (and (= :post (:request-method req))\n- (= \"/token\" (:uri req)))\n- (let [n (swap! !call-count inc)\n- sub (nth google-users (mod (dec n) (count google-users)))]\n- {:status 200\n- :headers {\"Content-Type\" \"application/json\"}\n- :body (json/generate-string {:id_token (make-id-token sub)})})\n-\n- :else\n- {:status 404 :body \"not found\"}))\n- stop-fn (http/run-server handler {:port port})]\n- {:stop-fn stop-fn :port port}))\n+ (common/test-assert! counts pred msg))\n \n (defn auth-test [& _args]\n (println \"\\n━━━ auth v3 integration check ━━━\\n\")\n@@ -117,8 +18,7 @@\n \n (println \"building server + CLI binaries…\")\n (common/letlocals\n- (bind build @(p/process [(common/cargo-bin) \"build\" \"--release\" \"-p\" \"slugsocial-server\" \"-p\" \"slugsocial\"]\n- {:inherit true :env common/base-env}))\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\" \"slugsocial\"]))\n (assert! (zero? (:exit build)) \"cargo build succeeds\")\n (bind server-bin \"target/release/slugsocial-server\")\n (bind cli-bin \"target/release/slugsocial\")\n@@ -134,19 +34,10 @@\n (bind !server (atom nil))\n (bind !google (atom nil))\n \n- (bind server-env (merge common/base-env\n- {\"SLUG_DATA_DIR\" tmp-dir\n- \"SLUG_KEYS\" \"test:test\"\n- \"PORT\" (str slug-port)\n- \"RUST_LOG\" \"warn\"\n- \"SLUG_PUBLIC_URL\" base-url\n- \"SLUG_GOOGLE_AUTH_URL\" (str google-url \"/o/oauth2/v2/auth\")\n- \"SLUG_GOOGLE_TOKEN_URL\" (str google-url \"/token\")\n- \"SLUG_GOOGLE_CLIENT_ID\" \"mock\"\n- \"SLUG_GOOGLE_CLIENT_SECRET\" \"mock\"}))\n+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))\n (try\n (println (str \"\\nstarting mock google on :\" google-port))\n- (reset! !google (start-mock-google google-port))\n+ (reset! !google (oauth/start-mock-google google-port :google-users [\"google-user-1\" \"google-user-2\"]))\n (assert! (some? (:stop-fn @!google)) \"mock google started\")\n \n (println (str \"starting server on :\" slug-port))\n@@ -154,34 +45,33 @@\n (assert! (common/wait-for-server base-url 10000) \"server responds to /healthz\")\n \n (println \"\\nstarting pending session…\")\n- (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n- {:agent \"00000000-0000-0000-0000-000000000000:bb:local/dev\"})\n+ (let [start-resp (oauth/http-post-json (str base-url \"/api/v0/pending-session\")\n+ {:agent \"00000000-0000-0000-0000-000000000000:bb:local/dev\"})\n _ (assert! (= 200 (:status start-resp)) \"pending-session start returns 200\")\n start-json (json/parse-string (:body start-resp) true)]\n- (assert! (clojure.string/starts-with? (:session start-json) \"p_\") \"session id has p_ prefix\")\n- (assert! (clojure.string/includes? (:login_url start-json) \"/auth/login\") \"login_url provided\")\n+ (assert! (str/starts-with? (:session start-json) \"p_\") \"session id has p_ prefix\")\n+ (assert! (str/includes? (:login_url start-json) \"/auth/login\") \"login_url provided\")\n \n (println \"\\nsimulating browser oauth redirects…\")\n- ;; This will follow redirects: /auth/login -> mock google -> /auth/callback -> /auth/choose-username\n- (let [login-get (http-get (:login_url start-json))]\n+ (let [login-get (oauth/http-get (:login_url start-json))]\n (assert! (= 200 (:status login-get)) \"choose-username page reachable after oauth callback\"))\n \n (println \"\\nchoosing username…\")\n- (let [choose (http-post-form (str base-url \"/auth/choose-username\")\n- {:session (:session start-json) :username \"bbuser\"})]\n+ (let [choose (oauth/http-post-form (str base-url \"/auth/choose-username\")\n+ {:session (:session start-json) :username \"bbuser\"})]\n (assert! (= 200 (:status choose)) \"choose-username POST returns 200\"))\n \n (println \"\\npolling pending session…\")\n- (let [poll (http-get (str base-url \"/api/v0/pending-session/\" (:session start-json)))]\n+ (let [poll (oauth/http-get (str base-url \"/api/v0/pending-session/\" (:session start-json)))]\n (assert! (= 200 (:status poll)) \"pending-session poll returns 200\")\n (let [poll-json (json/parse-string (:body poll) true)]\n (assert! (:complete poll-json) \"pending session complete=true\")\n (assert! (= \"bbuser\" (:user poll-json)) \"poll returns stored username bbuser\")\n- (assert! (clojure.string/starts-with? (:token poll-json) \"slug_\") \"poll returns bearer token\")\n+ (assert! (str/starts-with? (:token poll-json) \"slug_\") \"poll returns bearer token\")\n \n (println \"\\nwhoami…\")\n- (let [who (http-get (str base-url \"/api/v0/whoami\")\n- :headers {\"Authorization\" (str \"Bearer \" (:token poll-json))})]\n+ (let [who (oauth/http-get (str base-url \"/api/v0/whoami\")\n+ :headers {\"Authorization\" (str \"Bearer \" (:token poll-json))})]\n (assert! (= 200 (:status who)) \"whoami returns 200\")\n (let [who-json (json/parse-string (:body who) true)]\n (assert! (= \"bbuser\" (:user who-json)) \"whoami user is bbuser (stored form)\"))))))\n@@ -195,11 +85,11 @@\n (str \"identity start exits 0 (stderr: \" (:err start-proc) \")\"))\n (let [start-cli (json/parse-string (:out start-proc) true)]\n (assert! (= \"present_oauth_url_to_user\" (:phase start-cli)) \"identity start --json phase\")\n- (assert! (clojure.string/starts-with? (:session start-cli) \"p_\") \"CLI start session id\")\n- (let [login-get (http-get (:login_url start-cli))]\n+ (assert! (str/starts-with? (:session start-cli) \"p_\") \"CLI start session id\")\n+ (let [login-get (oauth/http-get (:login_url start-cli))]\n (assert! (= 200 (:status login-get)) \"CLI login_url redirect chain succeeds\"))\n- (let [choose (http-post-form (str base-url \"/auth/choose-username\")\n- {:session (:session start-cli) :username \"cliuser\"})]\n+ (let [choose (oauth/http-post-form (str base-url \"/auth/choose-username\")\n+ {:session (:session start-cli) :username \"cliuser\"})]\n (assert! (= 200 (:status choose)) \"choose-username for cliuser\"))\n (let [poll-proc @(p/process [cli-bin \"identity\" \"poll\" (:session start-cli)\n \"--poll-interval-ms\" \"100\" \"--max-wait-secs\" \"30\" \"--json\"]\n@@ -209,10 +99,10 @@\n (let [poll-cli (json/parse-string (:out poll-proc) true)]\n (assert! (= \"complete\" (:phase poll-cli)) \"identity poll --json phase\")\n (assert! (= \"cliuser\" (:user poll-cli)) \"CLI poll user (stored form)\")\n- (assert! (clojure.string/starts-with? (:token poll-cli) \"slug_\") \"CLI poll token\")\n+ (assert! (str/starts-with? (:token poll-cli) \"slug_\") \"CLI poll token\")\n (let [token-path (str cli-home \"/.config/slugsocial/token\")]\n (assert! (fs/exists? token-path) \"token written under isolated HOME\")\n- (assert! (= (clojure.string/trim (slurp token-path)) (:token poll-cli))\n+ (assert! (= (str/trim (slurp token-path)) (:token poll-cli))\n \"token file matches poll JSON\"))\n (let [who-proc @(p/process [cli-bin \"whoami\" \"--json\"]\n {:out :string :err :string :env cli-env})]\n@@ -227,5 +117,4 @@\n (fs/delete-tree tmp-dir)))\n \n (bind {pass :pass} @counts)\n- (println (str \"\\n\" ansi-green \"━━━ \" pass \" auth checks passed ━━━\" ansi-reset \"\\n\"))))\n-\n+ (println (str \"\\n\" common/ansi-green \"━━━ \" pass \" auth checks passed ━━━\" common/ansi-reset \"\\n\"))))\ndiff --git a/test/common.bb b/test/common.bb\nindex 4ed450377cdbb020f5ff164a812dfbbfc23c0f47..0b855ea5fc6cc513df065a221a28eae6e0848edc 100644\n--- a/test/common.bb\n+++ b/test/common.bb\n@@ -1,6 +1,7 @@\n (ns test.common\n \"Shared utilities for slug test rigs: port allocation, server lifecycle,\n- environment helpers, CLI runner, and the letlocals macro.\"\n+ environment helpers, CLI runner, test harness (ANSI + pass/fail counts),\n+ standard server env for bb suites, and the letlocals macro.\"\n (:require [babashka.process :as p]\n [clojure.string :as str]\n [babashka.fs :as fs]))\n@@ -43,6 +44,42 @@\n env\n (assoc env \"PATH\" (str cargo-home \":\" (get env \"PATH\" \"\")))))))\n \n+;; ---------------------------------------------------------------------------\n+;; test harness (ANSI + counters)\n+;; ---------------------------------------------------------------------------\n+\n+(def ansi-green \"\\033[32m\")\n+(def ansi-red \"\\033[31m\")\n+(def ansi-reset \"\\033[0m\")\n+\n+(defn test-pass! [counts-atom msg]\n+ (swap! counts-atom update :pass inc)\n+ (println (str ansi-green \" ✓ \" ansi-reset msg)))\n+\n+(defn test-fail! [counts-atom msg]\n+ (swap! counts-atom update :fail inc)\n+ (println (str ansi-red \" ✗ \" ansi-reset msg)))\n+\n+(defn test-assert! [counts-atom pred msg]\n+ (if pred\n+ (test-pass! counts-atom msg)\n+ (do (test-fail! counts-atom msg)\n+ (throw (ex-info (str \"FAIL: \" msg) {})))))\n+\n+(defn slug-server-env\n+ \"Env map for `slugsocial-server` in bb integration tests (mock Google URLs, data dir, keys).\"\n+ [tmp-dir base-url google-base-url slug-port]\n+ (merge base-env\n+ {\"SLUG_DATA_DIR\" tmp-dir\n+ \"SLUG_KEYS\" \"test:test\"\n+ \"PORT\" (str slug-port)\n+ \"RUST_LOG\" \"warn\"\n+ \"SLUG_PUBLIC_URL\" base-url\n+ \"SLUG_GOOGLE_AUTH_URL\" (str google-base-url \"/o/oauth2/v2/auth\")\n+ \"SLUG_GOOGLE_TOKEN_URL\" (str google-base-url \"/token\")\n+ \"SLUG_GOOGLE_CLIENT_ID\" \"mock\"\n+ \"SLUG_GOOGLE_CLIENT_SECRET\" \"mock\"}))\n+\n (defn cargo-bin\n \"Resolve cargo, preferring ~/.cargo/bin if not on PATH.\"\n []\n@@ -50,6 +87,13 @@\n (str cargo-home \"/cargo\")\n \"cargo\"))\n \n+(defn run-cargo-build-release!\n+ \"`cargo build --release` for each package name in `packages` (e.g. [\\\"slugsocial-server\\\"]).\"\n+ [packages]\n+ @(p/process (into [(cargo-bin) \"build\" \"--release\"]\n+ (mapcat (fn [p] [\"-p\" p]) packages))\n+ {:inherit true :env base-env}))\n+\n ;; ---------------------------------------------------------------------------\n ;; port / server utilities\n ;; ---------------------------------------------------------------------------\ndiff --git a/test/grants.bb b/test/grants.bb\nindex 793ba216f2de76a77eb20a76d08403db07ff411b..10ef0f08ce1c3945e5f1634314e6aebcf6810cd2 100644\n--- a/test/grants.bb\n+++ b/test/grants.bb\n@@ -7,145 +7,20 @@\n - user with View + Post -> ok=true for prose\n - user with View + Post but no Vote -> ok=false for vote\n - user with View + Post + Vote -> ok=true for vote\"\n- (:require [babashka.process :as p]\n- [babashka.fs :as fs]\n+ (:require [babashka.fs :as fs]\n [cheshire.core :as json]\n- [org.httpkit.server :as http]\n- [test.common :as common]))\n+ [test.common :as common]\n+ [test.oauth :as oauth]))\n \n-(def ^:private ansi-green \"\\033[32m\")\n-(def ^:private ansi-red \"\\033[31m\")\n-(def ^:private ansi-reset \"\\033[0m\")\n (def ^:private counts (atom {:pass 0 :fail 0}))\n \n-(defn- pass [msg]\n- (swap! counts update :pass inc)\n- (println (str ansi-green \" ✓ \" ansi-reset msg)))\n-\n-(defn- fail [msg]\n- (swap! counts update :fail inc)\n- (println (str ansi-red \" ✗ \" ansi-reset msg)))\n-\n (defn- assert! [pred msg]\n- (if pred\n- (pass msg)\n- (do (fail msg)\n- (throw (ex-info (str \"FAIL: \" msg) {})))))\n-\n-(defn- http-client []\n- (-> (java.net.http.HttpClient/newBuilder)\n- (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)\n- (.connectTimeout (java.time.Duration/ofSeconds 15))\n- (.build)))\n-\n-(defn- http-get [url & {:keys [headers]}]\n- (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (doseq [[k v] (or headers {})]\n- (.header b k v))\n- (let [req (-> b (.timeout (java.time.Duration/ofSeconds 60)) (.GET) (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp)})))\n-\n-(defn- http-post-json [url data & {:keys [headers]}]\n- (let [body (json/generate-string data)\n- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (.header b \"Content-Type\" \"application/json\")\n- (doseq [[k v] (or headers {})]\n- (.header b k v))\n- (let [req (-> b\n- (.timeout (java.time.Duration/ofSeconds 60))\n- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))\n- (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp)})))\n-\n-(defn- http-post-form [url form]\n- (let [pairs (->> form\n- (map (fn [[k v]]\n- (str (java.net.URLEncoder/encode (name k) \"UTF-8\")\n- \"=\"\n- (java.net.URLEncoder/encode (str v) \"UTF-8\"))))\n- (clojure.string/join \"&\"))\n- b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n- (.header b \"Content-Type\" \"application/x-www-form-urlencoded\")\n- (let [req (-> b\n- (.timeout (java.time.Duration/ofSeconds 60))\n- (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))\n- (.build))\n- resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n- {:status (.statusCode resp) :body (.body resp)})))\n-\n-(defn- parse-query [s]\n- (into {}\n- (for [part (clojure.string/split (or s \"\") #\"&\")\n- :when (not (clojure.string/blank? part))]\n- (let [[k v] (clojure.string/split part #\"=\" 2)]\n- [(keyword (java.net.URLDecoder/decode k \"UTF-8\"))\n- (some-> v (java.net.URLDecoder/decode \"UTF-8\"))]))))\n-\n-(defn- b64url [s]\n- (-> (java.util.Base64/getUrlEncoder)\n- (.withoutPadding)\n- (.encodeToString (.getBytes s \"UTF-8\"))))\n-\n-(defn- make-id-token [sub]\n- (str (b64url \"{\\\"alg\\\":\\\"RS256\\\",\\\"typ\\\":\\\"JWT\\\"}\")\n- \".\"\n- (b64url (json/generate-string {:sub sub}))\n- \".fakesig\"))\n-\n-;; Stateful mock: each /token call returns the next user in the list.\n-;; This lets us register two distinct users in one test run.\n-(defn- start-mock-google [port]\n- (let [google-users [\"google-user-alice\" \"google-user-bob\"]\n- !call-count (atom 0)\n- handler\n- (fn [req]\n- (cond\n- (and (= :get (:request-method req))\n- (= \"/o/oauth2/v2/auth\" (:uri req)))\n- (let [q (parse-query (:query-string req))\n- redirect-uri (:redirect_uri q)\n- state (:state q)\n- loc (str redirect-uri \"?code=mockcode&state=\" state)]\n- {:status 302 :headers {\"Location\" loc} :body \"\"})\n-\n- (and (= :post (:request-method req))\n- (= \"/token\" (:uri req)))\n- (let [n (swap! !call-count inc)\n- sub (nth google-users (mod (dec n) (count google-users)))]\n- {:status 200\n- :headers {\"Content-Type\" \"application/json\"}\n- :body (json/generate-string {:id_token (make-id-token sub)})})\n-\n- :else\n- {:status 404 :body \"not found\"}))\n- stop-fn (http/run-server handler {:port port})]\n- {:stop-fn stop-fn :port port}))\n-\n-(defn- register-user\n- \"Walk the full OAuth flow for one user. Returns the bearer token.\"\n- [base-url session-agent username]\n- (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n- {:agent session-agent})\n- _ (assert! (= 200 (:status start-resp))\n- (str \"pending-session for \" username \" returns 200\"))\n- start-json (json/parse-string (:body start-resp) true)\n- _login (http-get (:login_url start-json))\n- _choose (http-post-form (str base-url \"/auth/choose-username\")\n- {:session (:session start-json) :username username})\n- _ (assert! (= 200 (:status _choose))\n- (str \"choose-username for \" username \" returns 200\"))\n- poll (http-get (str base-url \"/api/v0/pending-session/\" (:session start-json)))\n- poll-json (json/parse-string (:body poll) true)\n- _ (assert! (:complete poll-json)\n- (str \"pending session complete for \" username))]\n- (:token poll-json)))\n+ (common/test-assert! counts pred msg))\n \n (defn- bearer [token] {\"Authorization\" (str \"Bearer \" token)})\n \n (defn- rpc-batch! [base-url token cmds]\n- (let [resp (http-post-json (str base-url \"/api/v0/rpc\") cmds :headers (bearer token))]\n+ (let [resp (oauth/http-post-json (str base-url \"/api/v0/rpc\") cmds :headers (bearer token))]\n {:status (:status resp)\n :parsed (json/parse-string (:body resp) false)}))\n \n@@ -160,14 +35,19 @@\n \"text\" text\n \"return_rank_diff\" false}}]))\n \n+(defn- register-user! [base-url session-agent username]\n+ (oauth/complete-registration! base-url\n+ :agent session-agent\n+ :username username\n+ :assert! (fn [pred msg] (assert! pred msg))))\n+\n (defn grants-test [& _args]\n (println \"\\n━━━ grants enforcement integration check ━━━\\n\")\n (reset! counts {:pass 0 :fail 0})\n \n (println \"building server binary…\")\n (common/letlocals\n- (bind build @(p/process [(common/cargo-bin) \"build\" \"--release\" \"-p\" \"slugsocial-server\"]\n- {:inherit true :env common/base-env}))\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\"]))\n (assert! (zero? (:exit build)) \"cargo build succeeds\")\n (bind server-bin \"target/release/slugsocial-server\")\n \n@@ -180,33 +60,24 @@\n (bind !server (atom nil))\n (bind !google (atom nil))\n \n- (bind server-env (merge common/base-env\n- {\"SLUG_DATA_DIR\" tmp-dir\n- \"SLUG_KEYS\" \"test:test\"\n- \"PORT\" (str slug-port)\n- \"RUST_LOG\" \"warn\"\n- \"SLUG_PUBLIC_URL\" base-url\n- \"SLUG_GOOGLE_AUTH_URL\" (str google-url \"/o/oauth2/v2/auth\")\n- \"SLUG_GOOGLE_TOKEN_URL\" (str google-url \"/token\")\n- \"SLUG_GOOGLE_CLIENT_ID\" \"mock\"\n- \"SLUG_GOOGLE_CLIENT_SECRET\" \"mock\"}))\n+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))\n (try\n (println (str \"starting mock google on :\" google-port))\n- (reset! !google (start-mock-google google-port))\n+ (reset! !google (oauth/start-mock-google google-port\n+ :google-users [\"google-user-alice\" \"google-user-bob\"]))\n \n (println (str \"starting server on :\" slug-port))\n (reset! !server (common/start-server server-bin server-env))\n (assert! (common/wait-for-server base-url 10000) \"server responds to /healthz\")\n \n- ;; Register two users. The mock google cycles through google-user-alice then google-user-bob.\n (println \"\\nregistering alice…\")\n- (let [alice-token (register-user base-url\n- \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n- \"alice\")\n+ (let [alice-token (register-user! base-url\n+ \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n+ \"alice\")\n _ (println \"registering bob…\")\n- bob-token (register-user base-url\n- \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n- \"bob\")\n+ bob-token (register-user! base-url\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"bob\")\n \n ;; Alice creates a private room.\n _ (println \"\\nalice creates private room…\")\n@@ -288,6 +159,6 @@\n \n (bind {pass :pass fail :fail} @counts)\n (if (zero? fail)\n- (println (str \"\\n\" ansi-green \"━━━ \" pass \" grant checks passed ━━━\" ansi-reset \"\\n\"))\n- (do (println (str \"\\n\" ansi-red \"━━━ \" fail \" grant checks FAILED ━━━\" ansi-reset \"\\n\"))\n+ (println (str \"\\n\" common/ansi-green \"━━━ \" pass \" grant checks passed ━━━\" common/ansi-reset \"\\n\"))\n+ (do (println (str \"\\n\" common/ansi-red \"━━━ \" fail \" grant checks FAILED ━━━\" common/ansi-reset \"\\n\"))\n (System/exit 1)))))\ndiff --git a/test/integration.bb b/test/integration.bb\nindex 07c9851e5e112f644556a847302d00b5e0f6acb4..c9af7afb3942ffb509a7d053b903c36cdbf1691b 100644\n--- a/test/integration.bb\n+++ b/test/integration.bb\n@@ -2,8 +2,7 @@\n \"End-to-end integration check: DSL → CLI → HTTP → JSONL → materialized view → CLI query.\n Boots a server, ingests via CLI, queries back, kills server, restarts from\n the same JSONL, and queries again to prove replay determinism.\"\n- (:require [babashka.process :as p]\n- [clojure.string :as str]\n+ (:require [clojure.string :as str]\n [babashka.fs :as fs]\n [cheshire.core :as json]\n [test.common :as common]\n@@ -13,25 +12,10 @@\n ;; helpers\n ;; ---------------------------------------------------------------------------\n \n-(def ^:private ansi-green \"\\033[32m\")\n-(def ^:private ansi-red \"\\033[31m\")\n-(def ^:private ansi-reset \"\\033[0m\")\n-\n (def ^:private counts (atom {:pass 0 :fail 0}))\n \n-(defn- pass [msg]\n- (swap! counts update :pass inc)\n- (println (str ansi-green \" ✓ \" ansi-reset msg)))\n-\n-(defn- fail [msg]\n- (swap! counts update :fail inc)\n- (println (str ansi-red \" ✗ \" ansi-reset msg)))\n-\n (defn- assert! [pred msg]\n- (if pred\n- (pass msg)\n- (do (fail msg)\n- (throw (ex-info (str \"FAIL: \" msg) {})))))\n+ (common/test-assert! counts pred msg))\n \n ;; ---------------------------------------------------------------------------\n ;; letlocals unit tests\n@@ -102,8 +86,7 @@\n (println \"\\nbuilding binaries…\")\n (common/letlocals\n ;; 1. build\n- (bind build @(p/process [(common/cargo-bin) \"build\" \"--release\" \"-p\" \"slugsocial-server\" \"-p\" \"slugsocial\"]\n- {:inherit true :env common/base-env}))\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\" \"slugsocial\"]))\n (assert! (zero? (:exit build)) \"cargo build succeeds\")\n \n (bind server-bin \"target/release/slugsocial-server\")\n@@ -120,16 +103,7 @@\n (bind !server (atom nil))\n (bind !server2 (atom nil))\n (bind !google (atom nil))\n- (bind server-env (merge common/base-env\n- {\"SLUG_DATA_DIR\" tmp-dir\n- \"SLUG_KEYS\" \"test:test\"\n- \"PORT\" (str port)\n- \"RUST_LOG\" \"warn\"\n- \"SLUG_PUBLIC_URL\" base-url\n- \"SLUG_GOOGLE_AUTH_URL\" (str google-url \"/o/oauth2/v2/auth\")\n- \"SLUG_GOOGLE_TOKEN_URL\" (str google-url \"/token\")\n- \"SLUG_GOOGLE_CLIENT_ID\" \"mock\"\n- \"SLUG_GOOGLE_CLIENT_SECRET\" \"mock\"}))\n+ (bind server-env (common/slug-server-env tmp-dir base-url google-url port))\n \n (try\n (common/letlocals\n@@ -291,4 +265,4 @@\n (fs/delete-tree tmp-dir)))\n \n (bind {pass :pass} @counts)\n- (println (str \"\\n\" ansi-green \"━━━ \" pass \" checks passed ━━━\" ansi-reset \"\\n\"))))\n+ (println (str \"\\n\" common/ansi-green \"━━━ \" pass \" checks passed ━━━\" common/ansi-reset \"\\n\"))))\ndiff --git a/test/oauth.bb b/test/oauth.bb\nindex 84679e1a4adbe100dfe7e7d64a2c6270b023d275..b69459acd17c23c023170d8dd93fe45bb49c8d88 100644\n--- a/test/oauth.bb\n+++ b/test/oauth.bb\n@@ -1,18 +1,23 @@\n (ns test.oauth\n \"Shared Babashka HTTP helpers + mock Google OAuth + token handoff for integration tests.\"\n (:require [cheshire.core :as json]\n+ [clojure.string :as str]\n [org.httpkit.server :as http]))\n \n+(def ^:private connect-timeout (java.time.Duration/ofSeconds 15))\n+(def ^:private request-timeout (java.time.Duration/ofSeconds 60))\n+\n (defn http-client []\n (-> (java.net.http.HttpClient/newBuilder)\n (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)\n+ (.connectTimeout connect-timeout)\n (.build)))\n \n (defn http-get [url & {:keys [headers]}]\n (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n (doseq [[k v] (or headers {})]\n (.header b k v))\n- (let [req (-> b (.GET) (.build))\n+ (let [req (-> b (.timeout request-timeout) (.GET) (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n \n@@ -23,6 +28,7 @@\n (doseq [[k v] (or headers {})]\n (.header b k v))\n (let [req (-> b\n+ (.timeout request-timeout)\n (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))\n (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n@@ -34,10 +40,11 @@\n (str (java.net.URLEncoder/encode (name k) \"UTF-8\")\n \"=\"\n (java.net.URLEncoder/encode (str v) \"UTF-8\"))))\n- (clojure.string/join \"&\"))\n+ (str/join \"&\"))\n b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n (.header b \"Content-Type\" \"application/x-www-form-urlencoded\")\n (let [req (-> b\n+ (.timeout request-timeout)\n (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))\n (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n@@ -45,9 +52,9 @@\n \n (defn parse-query [s]\n (into {}\n- (for [part (clojure.string/split (or s \"\") #\"&\")\n- :when (not (clojure.string/blank? part))]\n- (let [[k v] (clojure.string/split part #\"=\" 2)]\n+ (for [part (str/split (or s \"\") #\"&\")\n+ :when (not (str/blank? part))]\n+ (let [[k v] (str/split part #\"=\" 2)]\n [(keyword (java.net.URLDecoder/decode k \"UTF-8\"))\n (some-> v (java.net.URLDecoder/decode \"UTF-8\"))]))))\n \n@@ -62,8 +69,14 @@\n (b64url (json/generate-string {:sub sub}))\n \".fakesig\"))\n \n-(defn start-mock-google [port]\n- (let [handler\n+(defn start-mock-google\n+ \"Minimal Google OAuth stub. With multiple `google-users`, each `/token` call returns the next\n+ `sub` in order (wraps), so several registrations get distinct Google identities.\"\n+ [port & {:keys [google-users] :or {google-users [\"google-user-1\"]}}]\n+ (let [users (vec google-users)\n+ multi? (> (count users) 1)\n+ !call-count (when multi? (atom 0))\n+ handler\n (fn [req]\n (cond\n (and (= :get (:request-method req))\n@@ -76,9 +89,13 @@\n \n (and (= :post (:request-method req))\n (= \"/token\" (:uri req)))\n- {:status 200\n- :headers {\"Content-Type\" \"application/json\"}\n- :body (json/generate-string {:id_token (make-id-token \"google-user-1\")})}\n+ (let [sub (if multi?\n+ (let [n (swap! !call-count inc)]\n+ (nth users (mod (dec n) (count users))))\n+ (first users))]\n+ {:status 200\n+ :headers {\"Content-Type\" \"application/json\"}\n+ :body (json/generate-string {:id_token (make-id-token sub)})})\n \n :else\n {:status 404 :body \"not found\"}))\n@@ -87,28 +104,46 @@\n \n (def ^:private default-agent \"00000000-0000-0000-0000-000000000000:cli:local/dev\")\n \n+(defn complete-registration!\n+ \"Run pending-session → login redirect → choose-username → poll; return bearer token string.\n+ With `assert!`, use `(fn [pred msg] …)` for test-style checks; without it, throw `ex-info` on failure.\"\n+ [base-url & {:keys [username agent assert!]\n+ :or {username \"user\" agent default-agent}}]\n+ (let [check! (fn [pred msg resp]\n+ (if assert!\n+ (assert! pred msg)\n+ (when-not pred\n+ (throw (ex-info msg {:resp resp})))))]\n+ (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n+ {:agent agent})]\n+ (check! (= 200 (:status start-resp))\n+ (str \"pending-session for \" username \" returns 200\")\n+ start-resp)\n+ (let [start-json (json/parse-string (:body start-resp) true)\n+ login-get (http-get (:login_url start-json))]\n+ (check! (= 200 (:status login-get))\n+ (str \"login redirect chain for \" username)\n+ login-get)\n+ (let [choose (http-post-form (str base-url \"/auth/choose-username\")\n+ {:session (:session start-json) :username username})]\n+ (check! (= 200 (:status choose))\n+ (str \"choose-username for \" username \" returns 200\")\n+ choose)\n+ (let [poll (http-get (str base-url \"/api/v0/pending-session/\" (:session start-json)))]\n+ (check! (= 200 (:status poll))\n+ (str \"pending-session poll for \" username \" returns 200\")\n+ poll)\n+ (let [poll-json (json/parse-string (:body poll) true)]\n+ (check! (:complete poll-json)\n+ (str \"pending session complete for \" username)\n+ poll)\n+ (:token poll-json))))))))\n+\n (defn fetch-bearer-token!\n \"Simulate browser OAuth + username choice; returns `slug_…` bearer token.\n Ingest `--delegate` must match this agent string for `AgentBound` on first write.\"\n [base-url & {:keys [username agent] :or {username \"intuser\" agent default-agent}}]\n- (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n- {:agent agent})]\n- (when-not (= 200 (:status start-resp))\n- (throw (ex-info \"pending-session start failed\" {:resp start-resp})))\n- (let [start-json (json/parse-string (:body start-resp) true)\n- login-get (http-get (:login_url start-json))]\n- (when-not (= 200 (:status login-get))\n- (throw (ex-info \"login redirect chain failed\" {:resp login-get})))\n- (let [choose (http-post-form (str base-url \"/auth/choose-username\")\n- {:session (:session start-json) :username username})]\n- (when-not (= 200 (:status choose))\n- (throw (ex-info \"choose-username failed\" {:resp choose})))\n- (let [poll (http-get (str base-url \"/api/v0/pending-session/\" (:session start-json)))]\n- (when-not (= 200 (:status poll))\n- (throw (ex-info \"pending-session poll failed\" {:resp poll})))\n- (let [poll-json (json/parse-string (:body poll) true)]\n- (when-not (:complete poll-json)\n- (throw (ex-info \"pending session not complete\" {:poll poll-json})))\n- (when-not (clojure.string/starts-with? (:token poll-json) \"slug_\")\n- (throw (ex-info \"bad token in poll response\" {:poll poll-json})))\n- (:token poll-json)))))))\n+ (let [token (complete-registration! base-url :username username :agent agent)]\n+ (when-not (str/starts-with? token \"slug_\")\n+ (throw (ex-info \"bad token in poll response\" {:token token})))\n+ token))\n","role":"user"}],"model":"~x-ai/grok-latest"}