From c4edad8b7a777ed49d00913f622ae6d4b90e835d Mon Sep 17 00:00:00 2001 From: Lumen <315061740+LumenMuse@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:07:48 +0000 Subject: [PATCH] view: a blocked or missing post is an error, not an empty thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPostThread answers a blocked/deleted post with 200 and a #blockedPost / #notFoundPost stub that has no `post` field, so flatten_thread produced nothing and `view` printed nothing and exited 0 — the only silent failure in the CLI. Raise the same ApiError::Api the profile path raises (BlockedByActor/BlockedActor → exit 1, NotFound → exit 4), with two wiremock tests. Co-Authored-By: Claude Fable 5.1 --- src/commands/read.rs | 40 +++++++++++++++++++++++++++ tests/cli.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/commands/read.rs b/src/commands/read.rs index a4511ee..174f22c 100644 --- a/src/commands/read.rs +++ b/src/commands/read.rs @@ -45,6 +45,9 @@ pub async fn view(ctx: &Ctx, post: &str, depth: u32, parents: u32) -> anyhow::Re }; let mut posts = Vec::new(); flatten_thread(thread, &mut posts); + if posts.is_empty() { + return Err(thread_unavailable(thread, uri.as_str()).into()); + } for post in &posts { ctx.out.item(post, render_post); } @@ -81,6 +84,43 @@ fn flatten_replies(node: &Value, out: &mut Vec) { } } +/// `getPostThread` answers a blocked or missing post with a `2xx` whose +/// `thread` is a `#blockedPost` / `#notFoundPost` stub carrying no `post` +/// field, so flattening yields nothing and `view` used to print nothing +/// and exit 0 — the one silent face in the CLI. Turn the stub into the +/// same `ApiError::Api` the profile path raises, keeping the status the +/// server actually sent and letting `kind` carry the meaning +/// (`BlockedByActor` / `BlockedActor` → exit 1, `NotFound` → exit 4). +fn thread_unavailable(thread: &Value, uri: &str) -> ApiError { + let root = thread.get("$type").and_then(Value::as_str).unwrap_or(""); + match root { + "app.bsky.feed.defs#blockedPost" => { + let blocked_by = thread + .pointer("/author/viewer/blockedBy") + .and_then(Value::as_bool) + .unwrap_or(false); + let kind = if blocked_by { + "BlockedByActor" + } else { + "BlockedActor" + }; + ApiError::Api { + status: 200, + kind: kind.into(), + message: format!("getPostThread returned a blockedPost stub for {uri}"), + } + } + "app.bsky.feed.defs#notFoundPost" => ApiError::Api { + status: 200, + kind: "NotFound".into(), + message: format!("getPostThread returned a notFoundPost stub for {uri}"), + }, + other => ApiError::Unexpected(format!( + "getPostThread thread for {uri} carried no posts (root $type {other:?})" + )), + } +} + /// `fulmar me` — the author feed of the session's own account, /// straight from the session file (no resolution round-trip). pub async fn me(ctx: &Ctx, filter: &str, page: &PageArgs) -> anyhow::Result<()> { diff --git a/tests/cli.rs b/tests/cli.rs index 4e1980b..040e44a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -461,3 +461,68 @@ async fn two_processes_racing_one_session_never_double_spend_a_refresh_token() { "chain must have advanced cleanly, got {final_refresh:?}" ); } + +#[tokio::test(flavor = "multi_thread")] +async fn view_blocked_post_exits_1_not_silent_0() { + let server = MockServer::start().await; + let uri = format!("at://{DID}/app.bsky.feed.post/3wall"); + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.feed.getPostThread")) + .and(query_param("uri", uri.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "thread": { + "$type": "app.bsky.feed.defs#blockedPost", + "uri": uri, + "blocked": true, + "author": { "did": DID, "viewer": { "blockedBy": true } }, + }, + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().expect("tempdir"); + let session = seed_session(&dir, &server.uri(), "access-1", "refresh-1"); + let args_uri = uri.clone(); + let (code, stdout, stderr) = tokio::task::spawn_blocking(move || { + run(fulmar(&session).args(["view", args_uri.as_str(), "--json"])) + }) + .await + .expect("join"); + + assert_eq!(code, 1, "stdout: {stdout} stderr: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + assert!(stderr.contains("BlockedByActor"), "stderr: {stderr}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn view_not_found_stub_exits_4() { + let server = MockServer::start().await; + let uri = format!("at://{DID}/app.bsky.feed.post/3gone"); + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.feed.getPostThread")) + .and(query_param("uri", uri.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "thread": { + "$type": "app.bsky.feed.defs#notFoundPost", + "uri": uri, + "notFound": true, + }, + }))) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().expect("tempdir"); + let session = seed_session(&dir, &server.uri(), "access-1", "refresh-1"); + let args_uri = uri.clone(); + let (code, stdout, stderr) = tokio::task::spawn_blocking(move || { + run(fulmar(&session).args(["view", args_uri.as_str(), "--json"])) + }) + .await + .expect("join"); + + assert_eq!(code, 4, "stdout: {stdout} stderr: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + assert!(stderr.contains("NotFound"), "stderr: {stderr}"); +}