Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383//! `atgc string` end to end, against the mock PDSes and appview.
#![cfg(test)]
mod support;
use support::{ALICE, BOB, Scenario};
const STRING_NSID: &str = "org.tangled.snippet.string";
/// A CID the mock serves from an account's PDS. It need not hash the bytes:/// `getBlob` is addressed by the CID the record names.const BLOB_CID: &str = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
/// A current string record as tangled.org writes one, and the blob it names.fn plant_string( world: &Scenario, did: &str, rkey: &str, name: &str, description: &str, contents: &str, created_at: &str,) { world.with(|w| { w.blobs .insert(BLOB_CID.to_string(), contents.as_bytes().to_vec()); w.blob_owner.insert(BLOB_CID.to_string(), did.to_string()); w.plant( did, STRING_NSID, rkey, serde_json::json!({ "$type": STRING_NSID, "snippet": { "$type": "org.tangled.snippet.string#textFile", "name": name, "content": { "$type": "blob", "ref": { "$link": BLOB_CID }, "mimeType": "text/plain", "size": contents.len(), }, }, "description": description, "createdAt": created_at, }), ); });}
/// Create, list, read back and delete one string, each step seeing the last.#[test]fn a_string_round_trips_through_create_list_view_and_delete() { let world = Scenario::new("string-round-trip"); let file = world.scratch("paste").join("notes.md"); let contents = "# notes\n\n indented, and no trailing newline"; std::fs::write(&file, contents).unwrap();
let created = world .run(&[ "string", "create", file.to_str().unwrap(), "-d", "a checklist", "--json", ]) .success() .json(); assert_eq!(created["filename"], "notes.md", "{created:#}"); assert_eq!(created["description"], "a checklist"); assert_eq!(created["bytes"], serde_json::json!(contents.len())); let uri = created["uri"].as_str().expect("an at-uri").to_string(); let rkey = uri.rsplit('/').next().unwrap().to_string(); assert_eq!(uri, format!("at://{ALICE}/{STRING_NSID}/{rkey}")); assert!( created["url"] .as_str() .unwrap() .ends_with(&format!("/strings/alice.test/{rkey}")), "{created:#}" );
// The blob is uploaded first, then the record that names it. let labels = world.with(|w| w.labels()); let upload = labels .iter() .position(|l| l.ends_with("com.atproto.repo.uploadBlob")) .expect("an uploadBlob"); let create = labels .iter() .position(|l| l.ends_with("com.atproto.repo.createRecord")) .expect("a createRecord"); assert!(upload < create, "{labels:?}");
// The record is what tangled.org writes, field for field. let stored = world.records(ALICE, STRING_NSID); assert_eq!(stored.len(), 1); let value = &stored[0].1; assert_eq!( value.as_object().expect("a record object").len(), 4, "no field beyond the lexicon's: {value:#}" ); assert_eq!(value["$type"], STRING_NSID); assert_eq!(value["description"], "a checklist"); assert!(value["createdAt"].as_str().is_some(), "{value:#}"); assert_eq!(value["snippet"].as_object().unwrap().len(), 3, "{value:#}"); assert_eq!( value["snippet"]["$type"], "org.tangled.snippet.string#textFile" ); assert_eq!(value["snippet"]["name"], "notes.md"); assert_eq!( value["snippet"]["content"].as_object().unwrap().len(), 4, "{value:#}" ); assert_eq!(value["snippet"]["content"]["$type"], "blob"); assert_eq!(value["snippet"]["content"]["mimeType"], "text/plain"); assert_eq!( value["snippet"]["content"]["size"], serde_json::json!(contents.len()) ); let cid = value["snippet"]["content"]["ref"]["$link"] .as_str() .expect("the blob reference"); assert_eq!(world.blob(cid), contents.as_bytes());
let listed = world.run(&["string", "list", "--json"]).success().json(); assert_eq!(listed.as_array().unwrap().len(), 1, "{listed:#}"); assert_eq!(listed[0]["rkey"], rkey.as_str()); assert_eq!(listed[0]["filename"], "notes.md");
// There is no page per account, and `/strings/<did>` is a 404, so the // listing prints no link at all. let text = world.run(&["string", "list"]).success(); assert!(text.stdout.contains("notes.md"), "{}", text.stdout); assert!(!text.stdout.contains("view:"), "{}", text.stdout);
// Byte for byte: --raw adds no newline and trims nothing. let raw = world.run(&["string", "view", &rkey, "--raw"]).success(); assert_eq!(raw.stdout, contents);
let deleted = world .run(&["string", "delete", &rkey, "--json"]) .success() .json(); assert_eq!(deleted["deleted"], true, "{deleted:#}"); assert_eq!(deleted["uri"], format!("at://{ALICE}/{STRING_NSID}/{rkey}")); assert!(world.records(ALICE, STRING_NSID).is_empty()); let after = world.run(&["string", "list", "--json"]).success().json(); assert_eq!(after, serde_json::json!([]));}
/// Somebody else's string, by the URL tangled.org shows it at and by at-uri.#[test]fn another_accounts_string_is_read_by_its_url() { let world = Scenario::new("string-view-url"); let contents = "line one\n\x1b]0;owned\x07line two\n"; plant_string( &world, BOB, "3mysw3uwzo52b", "hello.sh", "a greeting", contents, "2026-10-20T12:00:00Z", ); let url = "https://tangled.org/strings/bob.test/3mysw3uwzo52b";
let shown = world.run(&["string", "view", url]).success(); assert!(shown.stdout.starts_with("hello.sh\n"), "{}", shown.stdout); assert!(shown.stdout.contains("a greeting"), "{}", shown.stdout); assert!( shown .stdout .contains(&format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b")), "{}", shown.stdout ); // The author's escape sequence is drawn as nothing in the text view. assert!( shown.stdout.contains("line one\n]0;ownedline two"), "{}", shown.stdout ); assert!(!shown.stdout.contains('\x1b'), "{:?}", shown.stdout);
// --raw into a pipe is the stored bytes, escape and all. let raw = world .run(&["string", "view", &format!("{url}/raw"), "--raw"]) .success(); assert_eq!(raw.stdout, contents);
// An at-uri naming the current collection reads the same record. let at_uri = format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b"); let raw = world.run(&["string", "view", &at_uri, "--raw"]).success(); assert_eq!(raw.stdout, contents);}
/// A file the lexicon would refuse is refused before anything is written.#[test]fn an_over_limit_string_is_refused_before_any_write() { let world = Scenario::new("string-over-limit"); let dir = world.scratch("paste"); // 141 graphemes: one over the lexicon's limit for a filename. let long = dir.join(format!("{}.txt", "a".repeat(137))); std::fs::write(&long, "contents").unwrap(); world .run(&["string", "create", long.to_str().unwrap()]) .refused_with(2, "filename");
// 257 graphemes: one over the description limit. let short = dir.join("ok.txt"); std::fs::write(&short, "contents").unwrap(); world .run(&[ "string", "create", short.to_str().unwrap(), "--description", &"d".repeat(257), ]) .refused_with(2, "description");
// 1,000,001 bytes: one over the blob the lexicon accepts. let large = dir.join("large.txt"); std::fs::write(&large, "a".repeat(1_000_001)).unwrap(); world .run(&["string", "create", large.to_str().unwrap()]) .refused_with(2, "1000000");
let empty = dir.join("empty.txt"); std::fs::write(&empty, "\n\n").unwrap(); world .run(&["string", "create", empty.to_str().unwrap()]) .refused_with(2, "empty");
world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a refused string must not upload a blob" ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a refused string must not reach the PDS" ); });}
/// A login without the new collection's scope is refused by name, before the/// blob upload.#[test]fn creating_without_the_string_scope_is_refused_before_the_upload() { let world = Scenario::new("string-scope"); // Everything except the collection this command writes. world.set_scope(ALICE, "atproto"); let file = world.scratch("paste").join("notes.md"); std::fs::write(&file, "contents").unwrap();
let run = world .run(&["string", "create", file.to_str().unwrap()]) .refused_with(4, "missing scope"); assert!( run.stderr.contains(STRING_NSID), "the refusal should name the scope that is missing:\n{}", run.stderr ); world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a blob was uploaded without the scope" ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a record was written without the scope" ); });}
/// Delete acts on the acting account's strings, and refuses anybody else's.#[test]fn deleting_another_accounts_string_is_refused() { let world = Scenario::new("string-delete-foreign"); plant_string( &world, BOB, "3mysw3uwzo52b", "hello.sh", "", "echo hi\n", "2026-10-20T12:00:00Z", );
world .run(&[ "string", "delete", "https://tangled.org/strings/bob.test/3mysw3uwzo52b", ]) .refused_with(4, "belongs to"); world .run(&[ "string", "delete", &format!("at://{BOB}/{STRING_NSID}/3mysw3uwzo52b"), ]) .refused_with(4, "belongs to");
let deletes = world.with(|w| w.calls_to("com.atproto.repo.deleteRecord").len()); assert_eq!( deletes, 0, "nothing may be sent for another account's record" ); assert_eq!(world.records(BOB, STRING_NSID).len(), 1);}
/// A dry-run create uploads no blob and writes no record.#[test]fn a_dry_run_string_create_writes_nothing() { let world = Scenario::new("string-create-dry-run"); let file = world.scratch("paste").join("notes.md"); std::fs::write(&file, "contents").unwrap();
let report = world .run(&[ "string", "create", file.to_str().unwrap(), "--dry-run", "--json", ]) .success() .json(); assert_eq!(report["dry_run"], true, "{report:#}"); assert_eq!(report["filename"], "notes.md", "{report:#}"); assert!(world.records(ALICE, STRING_NSID).is_empty()); world.with(|w| { assert!( w.calls_to("com.atproto.repo.uploadBlob").is_empty(), "a dry run uploaded a blob: {:?}", w.labels() ); assert!( w.calls_to("com.atproto.repo.createRecord").is_empty(), "a dry run wrote a record: {:?}", w.labels() ); });}
/// A dry-run delete leaves the planted string where it was.#[test]fn a_dry_run_string_delete_leaves_the_record_in_place() { let world = Scenario::new("string-delete-dry-run"); plant_string( &world, ALICE, "3mysw3uwzo52b", "hello.sh", "a greeting", "echo hi\n", "2026-10-20T12:00:00Z", );
let report = world .run(&["string", "delete", "3mysw3uwzo52b", "--dry-run", "--json"]) .success() .json(); assert_eq!(report["dry_run"], true, "{report:#}"); assert_eq!(report["deleted"], false, "{report:#}"); assert_eq!(world.records(ALICE, STRING_NSID).len(), 1); world.with(|w| { assert!( w.calls_to("com.atproto.repo.deleteRecord").is_empty(), "a dry run deleted the record: {:?}", w.labels() ); });}