use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::*;
use crate::xrpc::social::{AVATARS_WWW, Avatar, MAPS_FILE, Social, SocialRecord};
const REPO: &str = "did:plc:abcdefghijklmnopqrstuvwx";
const OWNER: &str = "did:plc:owner";
const ALICE: &str = "did:plc:alice";
const BOB: &str = "did:plc:bob";
const CAROL: &str = "did:plc:carol";
const AVATAR: &str = "bafkreigh2akiscaildc7gnvtklbsfhdgwz72eolmpckbqr5ej26byp3uli";
struct Map {
source: String,
default: String,
rows: BTreeMap<String, String>,
}
struct Served {
_dir: tempfile::TempDir,
generation: PathBuf,
maps: BTreeMap<String, Map>,
}
impl Served {
fn parse(generation: &Path) -> BTreeMap<String, Map> {
let text = std::fs::read_to_string(generation.join("nginx/maps").join(MAPS_FILE)).unwrap();
let mut maps = BTreeMap::new();
let mut lines = text.lines();
while let Some(line) = lines.next() {
let Some(head) = line.strip_prefix("map ") else {
continue;
};
let (source, var) = head.trim_end_matches(" {").rsplit_once(' ').unwrap();
let mut map = Map {
source: source.to_string(),
default: String::new(),
rows: BTreeMap::new(),
};
for line in lines.by_ref().map(str::trim) {
if line == "}" {
break;
}
let line = line.trim_end_matches(';');
let unquote = |text: &str| text.trim_matches('"').to_string();
if let Some(default) = line.strip_prefix("default ") {
map.default = unquote(default);
} else {
let (key, value) = line.rsplit_once(' ').unwrap();
map.rows.insert(unquote(key), unquote(value));
}
}
maps.insert(var.to_string(), map);
}
maps
}
fn expand(&self, template: &str, args: &[(&str, &str)]) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(at) = rest.find('$') {
out.push_str(&rest[..at]);
let name: String = rest[at + 1..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
rest = &rest[at + 1 + name.len()..];
out.push_str(&match name.strip_prefix("arg_") {
Some(arg) => args
.iter()
.find(|(key, _)| *key == arg)
.map_or(String::new(), |(_, value)| value.to_string()),
None => self.resolve(&format!("${name}"), args),
});
}
out + rest
}
fn resolve(&self, var: &str, args: &[(&str, &str)]) -> String {
let map = &self.maps[var];
let key = self.expand(&map.source, args);
match map.rows.get(&key) {
Some(value) => value.clone(),
None => self.expand(&map.default, args),
}
}
fn get(&self, var: &str, args: &[(&str, &str)]) -> Option<Value> {
let file = self.resolve(var, args);
(!file.is_empty()).then(|| {
let path = self.generation.join(ATPROTO_DIR).join(file);
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
})
}
}
fn record(collection: &str, rkey: &str, author: &str, value: Value) -> SocialRecord {
SocialRecord {
collection: collection.into(),
rkey: rkey.into(),
uri: format!("at://{REPO}/{collection}/{rkey}"),
cid: format!("bafyrei{rkey}"),
value,
author: Some(author.into()),
editor: Some(author.into()),
}
}
fn uri(collection: &str, rkey: &str) -> String {
format!("at://{REPO}/{collection}/{rkey}")
}
fn ticket(rkey: &str, author: &str, state: &str, at: &str, pull: bool) -> SocialRecord {
let mut value = json!({
"$type": "org.tangled.track.ticket",
"title": { "text": rkey },
"state": state,
"createdAt": at,
});
if pull {
value["targetBranch"] = json!("main");
}
if rkey == "t1" {
value["assignees"] = json!([BOB]);
}
record("org.tangled.track.ticket", rkey, author, value)
}
fn strong(uri: &str) -> Value {
json!({ "uri": uri, "cid": "bafyreisubject" })
}
fn served() -> Served {
let fixture = fixture("sha1");
let mut settled = settle_fixture(&fixture);
let t1 = uri("org.tangled.track.ticket", "t1");
let c1 = uri("org.tangled.feed.comment", "c1");
settled.rows.social.extend([
ticket("t1", ALICE, "open", "2024-01-02T00:00:00Z", false),
ticket("t2", BOB, "closed", "2024-01-03T00:00:00Z", false),
ticket("p1", ALICE, "completed", "2024-01-04T00:00:00Z", true),
record(
"org.tangled.feed.comment",
"c1",
BOB,
json!({ "subject": strong(&t1), "body": { "text": "first" }, "createdAt": "2024-01-05T00:00:00Z" }),
),
record(
"org.tangled.feed.comment",
"c2",
ALICE,
json!({
"subject": strong(&t1),
"replyTo": strong(&c1),
"body": { "text": "second" },
"embed": { "repo": REPO, "commit": { "oid": "abc123" } },
"createdAt": "2024-01-06T00:00:00Z",
}),
),
record(
"org.tangled.feed.reaction",
"r1",
BOB,
json!({ "subject": strong(&t1), "reaction": "🎉", "createdAt": "2024-01-07T00:00:00Z" }),
),
record(
"org.tangled.feed.reaction",
"r2",
ALICE,
json!({ "subject": strong(&t1), "reaction": "👍", "createdAt": "2024-01-08T00:00:00Z" }),
),
record(
"org.tangled.review.patch",
"v1",
CAROL,
json!({ "ticket": "p1", "base": { "oid": "b1" }, "head": { "oid": "h1" }, "createdAt": "2024-01-04T00:00:00Z" }),
),
record(
"org.tangled.review.patch",
"v2",
ALICE,
json!({ "ticket": "p1", "base": { "oid": "b1" }, "head": { "oid": "h2" }, "createdAt": "2024-01-09T00:00:00Z" }),
),
record(
"org.tangled.label.definition",
"bug",
OWNER,
json!({ "name": "bug", "color": "#f00", "createdAt": "2024-01-01T00:00:00Z" }),
),
SocialRecord {
author: None,
editor: None,
..record(
"org.tangled.graph.collaborator",
BOB,
OWNER,
json!({ "createdAt": "2024-01-01T00:00:00Z" }),
)
},
]);
let dir = tempfile::tempdir().unwrap();
let avatars_dir = dir.path().join("avatars");
std::fs::create_dir_all(&avatars_dir).unwrap();
std::fs::write(avatars_dir.join(format!("{AVATAR}.png")), b"\x89PNG").unwrap();
let social = Social {
hostname: "knot.test".into(),
owner: Some(OWNER.into()),
handles: BTreeMap::from([
(OWNER.into(), "owner.test".into()),
(ALICE.into(), "alice.test".into()),
]),
avatars: BTreeMap::from([(
ALICE.into(),
Avatar {
cid: AVATAR.into(),
source: AVATAR.into(),
sized: false,
ext: "png".into(),
size: 4,
},
)]),
avatars_dir: Some(avatars_dir),
profiles: BTreeMap::from([(
ALICE.into(),
json!({ "description": "hi", "pinnedRepositories": [REPO] })
.as_object()
.unwrap()
.clone(),
)]),
handles_digest: String::new(),
};
let generation = dir.path().join("generation");
crate::xrpc::emit_with(&generation, std::slice::from_ref(&settled), &social).unwrap();
let maps = Served::parse(&generation);
Served {
_dir: dir,
generation,
maps,
}
}
fn encoded(value: &str) -> String {
form_encoded(value)
}
fn rkeys(items: &Value) -> Vec<String> {
items
.as_array()
.unwrap()
.iter()
.map(|item| {
item["uri"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string()
})
.collect()
}
#[test]
fn identities_resolve_by_did_and_cached_handle() {
let served = served();
let doc = served.get("$ks_soc_doc", &[("identifier", OWNER)]).unwrap();
assert_eq!(
doc,
json!({ "did": OWNER, "handle": "owner.test", "pds": "https://knot.test", "signing_key": "" })
);
let by_handle = served.get("$ks_soc_doc", &[("identifier", "owner.test")]);
assert_eq!(by_handle.unwrap()["did"], OWNER);
let author = served
.get("$ks_soc_doc", &[("identifier", &encoded(BOB))])
.unwrap();
assert_eq!(author["handle"], "handle.invalid");
let repo = served.get("$ks_soc_doc", &[("identifier", REPO)]).unwrap();
assert_eq!(repo["pds"], "https://knot.test");
assert!(
served
.get("$ks_soc_doc", &[("identifier", "did:plc:nobody")])
.is_none()
);
let actor = encoded(&format!("at://{ALICE}/org.tangled.actor.profile/self"));
let profile = served.get("$ks_soc_profile", &[("actor", &actor)]).unwrap();
assert_eq!(profile["value"]["$type"], "org.tangled.actor.profile");
let profiles = served
.get("$ks_soc_profiles", &[("actors", &actor)])
.unwrap();
assert_eq!(profiles["items"].as_array().unwrap().len(), 1);
let keys = served.get("$ks_soc_keys", &[("subject", ALICE)]).unwrap();
assert_eq!(keys, json!({ "items": [] }));
}
#[test]
fn the_generation_lists_every_account_its_social_answers_name() {
let served = served();
let accounts: Vec<String> = serde_json::from_slice(
&std::fs::read(served.generation.join(crate::xrpc::social::ACCOUNTS_FILE)).unwrap(),
)
.unwrap();
assert_eq!(accounts, [ALICE, BOB, CAROL, OWNER]);
}
#[test]
fn cached_avatars_are_linked_mapped_and_named_in_the_profile() {
let served = served();
let actor = |did: &str| format!("at://{did}/org.tangled.actor.profile/self");
let profile = served
.get("$ks_soc_profile", &[("actor", &actor(ALICE))])
.unwrap();
assert_eq!(
profile["value"],
json!({
"$type": "org.tangled.actor.profile",
"description": "hi",
"pinnedRepositories": [REPO],
"avatar": { "$type": "blob", "ref": { "$link": AVATAR }, "mimeType": "image/png", "size": 4 },
})
);
let profiles = served
.get("$ks_soc_profiles", &[("actors", &actor(ALICE))])
.unwrap();
assert_eq!(
profiles["items"][0]["value"]["avatar"]["ref"]["$link"],
AVATAR
);
let bob = served
.get("$ks_soc_profile", &[("actor", &actor(BOB))])
.unwrap();
assert_eq!(
bob["value"],
json!({ "$type": "org.tangled.actor.profile" })
);
let file = format!("{AVATAR}.png");
let map = &served.maps["$ks_avatar_file"];
assert_eq!(map.source, "$uri|$ks_avatar_size");
assert_eq!(map.default, "");
for size in ["", "42", "96", "192"] {
assert_eq!(
map.rows[&format!("/avatar/{ALICE}|{size}")],
file,
"unsized"
);
assert_eq!(
map.rows[&format!("/avatar/{}|{size}", encoded(ALICE))],
file
);
}
assert!(!map.rows.contains_key(&format!("/avatar/{BOB}|")));
let sizes = &served.maps["$ks_avatar_size"];
assert_eq!(sizes.source, "$arg_size");
assert_eq!(
["1", "42", "43", "96", "97", "192"].map(|asked| sizes.rows[asked].as_str()),
["42", "42", "96", "96", "192", "192"]
);
assert!(
!sizes.rows.contains_key("193"),
"past the biggest is the full avatar"
);
let linked = served.generation.join(AVATARS_WWW).join(&file);
assert_eq!(std::fs::read(linked).unwrap(), b"\x89PNG");
}
#[test]
fn repo_views_carry_the_manifest_knot_and_branch() {
let served = served();
let view = served
.get("$ks_soc_by_name", &[("owner", OWNER), ("name", "demo")])
.unwrap();
assert_eq!(
view["uri"],
format!("at://{REPO}/org.tangled.repo.manifest/self")
);
assert_eq!(view["value"]["declaration"]["name"], "demo");
assert_eq!(view["value"]["declaration"]["owner"], OWNER);
assert_eq!(view["knot"], "knot.test");
assert_eq!(
view["git"],
json!({ "defaultBranch": "main", "isEmpty": false })
);
assert!(view["cid"].as_str().unwrap().starts_with("bafyrei"));
let by_old_name = served.get(
"$ks_soc_by_name",
&[("ownerDid", &encoded(OWNER)), ("name", "demo")],
);
assert_eq!(by_old_name.as_ref(), Some(&view));
assert!(
served
.get("$ks_soc_by_name", &[("owner", OWNER), ("name", "nope")])
.is_none()
);
assert_eq!(
served.get("$ks_soc_by_did", &[("repoDid", REPO)]).as_ref(),
Some(&view)
);
let batch = served
.get("$ks_soc_by_dids", &[("dids", &encoded(REPO))])
.unwrap();
assert_eq!(batch["items"][0], view);
let listed = served.get("$ks_soc_repos", &[("subject", OWNER)]).unwrap();
assert_eq!(listed["items"], json!([view]));
let none = served.get("$ks_soc_repos", &[("subject", ALICE)]).unwrap();
assert_eq!(none["items"], json!([]));
}
#[test]
fn counts_answer_the_did_and_manifest_uri() {
let served = served();
let manifest = encoded(&format!("at://{REPO}/org.tangled.repo.manifest/self"));
let open = served.get("$ks_soc_n_issues", &[("subject", REPO), ("state", "open")]);
assert_eq!(open.unwrap(), json!({ "count": 1, "distinctAuthors": 1 }));
let all = served
.get("$ks_soc_n_issues", &[("repo", &manifest)])
.unwrap();
assert_eq!(all, json!({ "count": 2, "distinctAuthors": 2 }));
let merged = served.get(
"$ks_soc_n_pulls",
&[("subject", REPO), ("status", "merged")],
);
assert_eq!(merged.unwrap()["count"], 1);
let open_pulls = served.get("$ks_soc_n_pulls", &[("subject", REPO), ("status", "open")]);
assert_eq!(open_pulls.unwrap()["count"], 0);
let stars = served.get("$ks_soc_n_stars", &[("subject", REPO)]).unwrap();
assert_eq!(stars, json!({ "count": 0, "distinctAuthors": 0 }));
for var in ["$ks_soc_n_collaborators", "$ks_soc_n_labels"] {
assert_eq!(
served.get(var, &[("subject", REPO)]).unwrap()["count"],
1,
"{var}"
);
}
let unknown = served.get("$ks_soc_n_forks", &[("subject", "did:plc:nobody")]);
assert_eq!(unknown.unwrap()["count"], 0);
}
#[test]
fn search_lists_tickets_by_kind_and_state() {
let served = served();
let search = |args: &[(&str, &str)]| served.get("$ks_soc_search", args);
let issues = search(&[("repoDid", REPO), ("kind", "issue")]).unwrap();
assert_eq!(rkeys(&issues["hits"]), ["t2", "t1"]);
assert_eq!(issues["total"], 2);
assert_eq!(
issues["stateCounts"],
json!({ "open": 1, "closed": 1, "completed": 0 })
);
let first = &issues["hits"][1];
assert_eq!(first["author"], ALICE);
assert_eq!(first["state"], "open");
assert_eq!(first["commentCount"], 2);
assert_eq!(first["nsid"], "org.tangled.track.ticket");
let doc = &issues["data"][ALICE]["org.tangled.track.ticket:.repo"]["blue.microcosm.identity.resolveMiniDoc"];
assert_eq!(doc["handle"], "alice.test");
let open = search(&[
("repo", &encoded(REPO)),
("kind", "issue"),
("q", "is%3Aopen"),
]);
assert_eq!(rkeys(&open.unwrap()["hits"]), ["t1"]);
let closed = search(&[("repoDid", REPO), ("kind", "issue"), ("state", "closed")]);
assert_eq!(rkeys(&closed.unwrap()["hits"]), ["t2"]);
let merged = search(&[("repoDid", REPO), ("kind", "pull"), ("q", "is:merged")]).unwrap();
assert_eq!(rkeys(&merged["hits"]), ["p1"]);
assert_eq!(merged["stateCounts"]["completed"], 1);
let paged = search(&[
("repoDid", REPO),
("kind", "issue"),
("limit", "30"),
("offset", "0"),
]);
assert_eq!(paged.unwrap()["total"], 2);
assert!(
search(&[
("repoDid", REPO),
("kind", "issue"),
("limit", "30"),
("offset", "30")
])
.is_none()
);
assert_eq!(
served.resolve("$ks_soc_qstate", &[("q", "fix+the+bug")]),
"",
"free text is refused"
);
}
#[test]
fn search_pages_by_offset() {
let fixture = fixture("sha1");
let mut settled = settle_fixture(&fixture);
settled.rows.social.extend((0..12).map(|n| {
ticket(
&format!("i{n:02}"),
ALICE,
"open",
&format!("2024-02-{:02}T00:00:00Z", n + 1),
false,
)
}));
let dir = tempfile::tempdir().unwrap();
let generation = dir.path().join("generation");
crate::xrpc::emit_with(
&generation,
std::slice::from_ref(&settled),
&Social::default(),
)
.unwrap();
let served = Served {
maps: Served::parse(&generation),
generation,
_dir: dir,
};
let page = |offset: &str| {
served
.get(
"$ks_soc_search",
&[
("repoDid", REPO),
("kind", "issue"),
("limit", "10"),
("offset", offset),
],
)
.unwrap()
};
let first = page("");
assert_eq!(first["hits"].as_array().unwrap().len(), 10);
assert_eq!(first["cursor"], "10");
assert_eq!(first["total"], 12);
assert_eq!(rkeys(&page("10")["hits"]), ["i01", "i00"]);
assert!(page("10").get("cursor").is_none());
}
#[test]
fn issue_and_pull_lists_filter_by_state() {
let served = served();
let issues = served.get("$ks_soc_issues", &[("subject", REPO)]).unwrap();
assert_eq!(rkeys(&issues["items"]), ["t2", "t1"]);
assert_eq!(issues["total"], 2);
assert_eq!(issues["items"][1]["commentCount"], 2);
let open = served.get("$ks_soc_issues", &[("subject", REPO), ("state", "open")]);
assert_eq!(rkeys(&open.unwrap()["items"]), ["t1"]);
let merged = served.get("$ks_soc_pulls", &[("subject", REPO), ("status", "merged")]);
assert_eq!(rkeys(&merged.unwrap()["items"]), ["p1"]);
}
#[test]
fn ticket_views_name_the_author_repo_and_reactions() {
let served = served();
let t1 = uri("org.tangled.track.ticket", "t1");
let view = served
.get("$ks_soc_ticket", &[("uri", &encoded(&t1))])
.unwrap();
assert_eq!(view["uri"], t1);
assert_eq!(
view["author"],
json!({ "did": ALICE, "handle": "alice.test" })
);
assert_eq!(view["title"]["text"], "t1");
assert_eq!(view["state"], "open");
assert_eq!(view["assignees"], json!([BOB]));
assert!(view.get("targetBranch").is_none());
assert_eq!(view["repo"]["slug"], "demo");
assert_eq!(
view["repo"]["owner"],
json!({ "did": OWNER, "handle": "owner.test" })
);
assert_eq!(view["repo"]["openIssueCount"], 1);
let reactions = &view["data"][&t1]["org.tangled.feed.reaction:subject"]["org.tangled.query.enrichResponse#reactions"]
["reactions"];
assert_eq!(
*reactions,
json!([
{ "reaction": "👍", "count": 1, "reactors": [ALICE] },
{ "reaction": "🎉", "count": 1, "reactors": [BOB] },
])
);
let assignee = &view["data"][BOB]["org.tangled.track.ticket:.repo"]["blue.microcosm.identity.resolveMiniDoc"];
assert_eq!(assignee["handle"], "handle.invalid");
let pull = served.get(
"$ks_soc_ticket",
&[("uri", &uri("org.tangled.track.ticket", "p1"))],
);
assert_eq!(pull.unwrap()["targetBranch"], "main");
assert!(
served
.get(
"$ks_soc_ticket",
&[("uri", &uri("org.tangled.track.ticket", "zz"))]
)
.is_none()
);
}
#[test]
fn comments_and_patches_list_per_ticket() {
let served = served();
let t1 = uri("org.tangled.track.ticket", "t1");
let newest = served
.get(
"$ks_soc_comments",
&[("subject", &encoded(&t1)), ("order", "desc")],
)
.unwrap();
assert_eq!(rkeys(&newest["items"]), ["c2", "c1"]);
assert_eq!(newest["total"], 2);
let reply = &newest["items"][0];
assert_eq!(reply["editor"], ALICE);
assert_eq!(reply["body"]["text"], "second");
assert_eq!(
reply["replyTo"]["uri"],
uri("org.tangled.feed.comment", "c1")
);
assert_eq!(
reply["embed"],
json!({ "$type": "org.tangled.embed.commit#view", "repo": REPO, "commit": "abc123" })
);
let doc = &newest["data"][BOB]["org.tangled.feed.comment:.repo"]["blue.microcosm.identity.resolveMiniDoc"];
assert_eq!(doc["did"], BOB);
let oldest = served
.get("$ks_soc_comments", &[("subject", &t1), ("order", "asc")])
.unwrap();
assert_eq!(rkeys(&oldest["items"]), ["c1", "c2"]);
let none = served.get(
"$ks_soc_comments",
&[("subject", &uri("org.tangled.track.ticket", "t2"))],
);
assert_eq!(none.unwrap(), json!({ "items": [], "total": 0 }));
let p1 = uri("org.tangled.track.ticket", "p1");
let patches = served.get(
"$ks_soc_patches",
&[
("ticket", &encoded(&p1)),
("order", "asc"),
("limit", "500"),
],
);
let patches = patches.unwrap();
assert_eq!(rkeys(&patches["items"]), ["v1", "v2"]);
assert_eq!(patches["items"][1]["value"]["head"]["oid"], "h2");
let none = served.get("$ks_soc_patches", &[("ticket", &t1), ("order", "asc")]);
assert_eq!(none.unwrap()["items"], json!([]));
}
#[test]
fn repo_lists_answer_labels_collaborators_and_artifacts() {
let served = served();
let labels = served
.get("$ks_soc_labels", &[("subject", REPO), ("limit", "100")])
.unwrap();
assert_eq!(labels["items"][0]["value"]["name"], "bug");
assert_eq!(
labels["items"][0]["uri"],
uri("org.tangled.label.definition", "bug")
);
let collaborators = served
.get("$ks_soc_collaborators", &[("repo", REPO)])
.unwrap();
let grant = &collaborators["items"][0];
assert_eq!(grant["subject"], BOB);
assert_eq!(grant["addedBy"], OWNER);
assert_eq!(grant["effectiveSince"], "2024-01-01T00:00:00Z");
let artifacts = served
.get("$ks_soc_artifacts", &[("subject", REPO)])
.unwrap();
assert_eq!(artifacts["items"], json!([]));
}
#[test]
fn an_empty_generation_still_answers_the_defaults() {
let dir = tempfile::tempdir().unwrap();
let generation = dir.path().join("generation");
emit(&generation, &[]).unwrap();
let served = Served {
maps: Served::parse(&generation),
generation,
_dir: dir,
};
assert_eq!(
served.get("$ks_soc_repos", &[("subject", OWNER)]).unwrap()["items"],
json!([])
);
assert!(
served
.get("$ks_soc_doc", &[("identifier", OWNER)])
.is_none()
);
assert!(
served
.get("$ks_soc_search", &[("repoDid", REPO), ("kind", "issue")])
.is_none()
);
}
#[test]
fn a_settled_cob_names_its_opener() {
let mut fixture = fixture("sha1");
let source = CobSource::new(&fixture);
let alice = account("alice");
source
.cobs(&fixture.signer)
.open_ticket("3kticket00001", &alice, "hello");
let settled = source.settle(&mut fixture);
let ticket = settled
.rows
.social
.iter()
.find(|record| record.collection == "org.tangled.track.ticket")
.expect("the ticket is in the social digest");
assert_eq!(ticket.author.as_deref(), Some(ALICE));
assert_eq!(ticket.editor.as_deref(), Some(ALICE));
let refs = settled
.rows
.social
.iter()
.find(|record| record.collection == "org.tangled.git.refs")
.unwrap();
assert_eq!(
refs.value,
json!({ "defaultBranch": "main", "isEmpty": false })
);
}