aboutsummaryrefslogtreecommitdiffstats
path: root/adenosine-pds/src/db_bsky.rs
diff options
context:
space:
mode:
Diffstat (limited to 'adenosine-pds/src/db_bsky.rs')
-rw-r--r--adenosine-pds/src/db_bsky.rs778
1 files changed, 778 insertions, 0 deletions
diff --git a/adenosine-pds/src/db_bsky.rs b/adenosine-pds/src/db_bsky.rs
new file mode 100644
index 0000000..ee8e0f3
--- /dev/null
+++ b/adenosine-pds/src/db_bsky.rs
@@ -0,0 +1,778 @@
+/// Helper functions for doing database and repo operations relating to bluesky endpoints and
+/// records
+use crate::{AtpDatabase, AtpService, Result, XrpcError};
+use adenosine::app_bsky;
+use adenosine::identifiers::{AtUri, Did, DidOrHost, Nsid, Tid};
+use adenosine::ipld::{ipld_into_json_value, json_value_into_ipld};
+use adenosine::repo::Mutation;
+use anyhow::anyhow;
+use libipld::Cid;
+use rusqlite::params;
+use serde_json::json;
+use std::str::FromStr;
+
+/// Handles updating the database with creation, update, deletion of arbitrary records
+pub fn bsky_mutate_db(db: &mut AtpDatabase, did: &Did, mutations: Vec<Mutation>) -> Result<()> {
+ // TODO: this function could probably be refactored
+ let bsky_post: Nsid = Nsid::from_str("app.bsky.feed.post").unwrap();
+ let bsky_repost: Nsid = Nsid::from_str("app.bsky.feed.repost").unwrap();
+ let bsky_like: Nsid = Nsid::from_str("app.bsky.feed.like").unwrap();
+ let bsky_follow: Nsid = Nsid::from_str("app.bsky.graph.follow").unwrap();
+ for m in mutations.into_iter() {
+ match m {
+ Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val)
+ if ref_type == bsky_post =>
+ {
+ db.bsky_upsert_post(did, &tid, Some(val))?
+ }
+ Mutation::Delete(ref_type, tid) if ref_type == bsky_post => {
+ db.bsky_upsert_post(did, &tid, None)?
+ }
+ Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val)
+ if ref_type == bsky_repost =>
+ {
+ db.bsky_upsert_ref("repost", did, &tid, Some(val))?
+ }
+ Mutation::Delete(ref_type, tid) if ref_type == bsky_repost => {
+ db.bsky_upsert_ref("repost", did, &tid, None)?
+ }
+ Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val)
+ if ref_type == bsky_like =>
+ {
+ db.bsky_upsert_ref("like", did, &tid, Some(val))?
+ }
+ Mutation::Delete(ref_type, tid) if ref_type == bsky_like => {
+ db.bsky_upsert_ref("like", did, &tid, None)?
+ }
+ Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val)
+ if ref_type == bsky_follow =>
+ {
+ db.bsky_upsert_follow(did, &tid, Some(val))?
+ }
+ Mutation::Delete(ref_type, tid) if ref_type == bsky_follow => {
+ db.bsky_upsert_follow(did, &tid, None)?
+ }
+ _ => (),
+ }
+ }
+ Ok(())
+}
+
+// TODO: should probably return Result<Option<Profile>>?
+pub fn bsky_get_profile(srv: &mut AtpService, did: &Did) -> Result<app_bsky::Profile> {
+ // first get the profile record
+ let mut profile_cid: Option<Cid> = None;
+ let commit_cid = match srv.repo.lookup_commit(did)? {
+ Some(cid) => cid,
+ None => Err(anyhow!("repository not found: {}", did))?,
+ };
+ let last_commit = srv.repo.get_commit(&commit_cid)?;
+ let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?;
+ let prefix = "/app.bsky.actor.profile/";
+ for (mst_key, cid) in full_map.iter() {
+ if mst_key.starts_with(prefix) {
+ profile_cid = Some(*cid);
+ }
+ }
+ let (display_name, description): (Option<String>, Option<String>) =
+ if let Some(cid) = profile_cid {
+ let record: app_bsky::ProfileRecord =
+ serde_json::from_value(ipld_into_json_value(srv.repo.get_ipld(&cid)?))?;
+ (Some(record.displayName), record.description)
+ } else {
+ (None, None)
+ };
+ let mut stmt = srv
+ .atp_db
+ .conn
+ .prepare_cached("SELECT handle FROM account WHERE did = $1")?;
+ let handle: String = stmt.query_row(params!(did.to_string()), |row| row.get(0))?;
+ let mut stmt = srv
+ .atp_db
+ .conn
+ .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE did = $1")?;
+ let post_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?;
+ let mut stmt = srv
+ .atp_db
+ .conn
+ .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE did = $1")?;
+ let follows_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?;
+ let mut stmt = srv
+ .atp_db
+ .conn
+ .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE subject_did = $1")?;
+ let followers_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?;
+ let decl = app_bsky::DeclRef {
+ actorType: "app.bsky.system.actorUser".to_string(),
+ cid: "bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u".to_string(),
+ };
+ Ok(app_bsky::Profile {
+ did: did.to_string(),
+ handle,
+ creator: did.to_string(),
+ displayName: display_name,
+ description,
+ declaration: decl,
+ followersCount: followers_count,
+ followsCount: follows_count,
+ postsCount: post_count,
+ membersCount: 0,
+ myState: json!({}),
+ })
+}
+
+pub fn bsky_update_profile(
+ srv: &mut AtpService,
+ did: &Did,
+ profile: app_bsky::ProfileRecord,
+) -> Result<()> {
+ // get the profile record
+ let mut profile_tid: Option<Tid> = None;
+ let commit_cid = match srv.repo.lookup_commit(did)? {
+ Some(cid) => cid,
+ None => Err(anyhow!("repository not found: {}", did))?,
+ };
+ let last_commit = srv.repo.get_commit(&commit_cid)?;
+ let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?;
+ let prefix = "/app.bsky.actor.profile/";
+ for (mst_key, _cid) in full_map.iter() {
+ if mst_key.starts_with(prefix) {
+ profile_tid = Some(Tid::from_str(mst_key.split('/').nth(2).unwrap())?);
+ }
+ }
+ let profile_tid: Tid = profile_tid.unwrap_or(srv.tid_gen.next_tid());
+ let mutations: Vec<Mutation> = vec![Mutation::Update(
+ Nsid::from_str("app.bsky.actor.profile")?,
+ profile_tid,
+ json_value_into_ipld(serde_json::to_value(profile)?),
+ )];
+ let keypair = srv.pds_keypair.clone();
+ srv.repo.mutate_repo(did, &mutations, &keypair)?;
+ Ok(())
+}
+
+struct FeedRow {
+ pub item_did: Did,
+ pub item_handle: String,
+ pub item_post_tid: Tid,
+ pub item_post_cid: Cid,
+ pub indexed_at: String,
+}
+
+fn feed_row(row: &rusqlite::Row) -> Result<FeedRow> {
+ let item_did: String = row.get(0)?;
+ let item_did = Did::from_str(&item_did)?;
+ let item_handle = row.get(1)?;
+ let item_post_tid: String = row.get(2)?;
+ let item_post_tid = Tid::from_str(&item_post_tid)?;
+ let cid_string: String = row.get(3)?;
+ let item_post_cid = Cid::from_str(&cid_string)?;
+ let indexed_at: String = row.get(4)?;
+ Ok(FeedRow {
+ item_did,
+ item_handle,
+ item_post_tid,
+ item_post_cid,
+ indexed_at,
+ })
+}
+
+fn feed_row_to_item(srv: &mut AtpService, row: FeedRow) -> Result<app_bsky::FeedItem> {
+ let record_ipld = srv.repo.get_ipld(&row.item_post_cid)?;
+ let uri = format!(
+ "at://{}/{}/{}",
+ row.item_did, "app.bsky.feed.post", row.item_post_tid
+ );
+
+ let mut stmt = srv.atp_db.conn.prepare_cached(
+ "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'like' AND subject_uri = $1",
+ )?;
+ let like_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?;
+
+ let mut stmt = srv.atp_db.conn.prepare_cached(
+ "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'repost' AND subject_uri = $1",
+ )?;
+ let repost_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?;
+
+ let mut stmt = srv
+ .atp_db
+ .conn
+ .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE reply_to_parent_uri = $1")?;
+ let reply_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?;
+
+ let feed_item = app_bsky::FeedItem {
+ uri,
+ cid: row.item_post_cid.to_string(),
+ author: app_bsky::User {
+ did: row.item_did.to_string(),
+ handle: row.item_handle,
+ displayName: None, // TODO: fetch from profile (or cache)
+ },
+ repostedBy: None,
+ record: ipld_into_json_value(record_ipld),
+ embed: None,
+ replyCount: reply_count,
+ repostCount: repost_count,
+ upvoteCount: like_count,
+ downvoteCount: 0,
+ indexedAt: row.indexed_at,
+ myState: None,
+ };
+ Ok(feed_item)
+}
+
+pub fn bsky_get_timeline(srv: &mut AtpService, did: &Did) -> Result<app_bsky::GenericFeed> {
+ let mut feed: Vec<app_bsky::FeedItem> = vec![];
+ // TODO: also handle reposts
+ let rows = {
+ let mut stmt = srv.atp_db
+ .conn
+ .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did LEFT JOIN bsky_follow ON bsky_post.did = bsky_follow.subject_did WHERE bsky_follow.did = ?1 AND account.did IS NOT NULL ORDER BY bsky_post.tid DESC LIMIT 20")?;
+ let mut sql_rows = stmt.query(params!(did.to_string()))?;
+ let mut rows: Vec<FeedRow> = vec![];
+ while let Some(sql_row) = sql_rows.next()? {
+ let row = feed_row(sql_row)?;
+ rows.push(row);
+ }
+ rows
+ };
+ for row in rows {
+ feed.push(feed_row_to_item(srv, row)?);
+ }
+ Ok(app_bsky::GenericFeed { feed })
+}
+
+pub fn bsky_get_author_feed(srv: &mut AtpService, did: &Did) -> Result<app_bsky::GenericFeed> {
+ let mut feed: Vec<app_bsky::FeedItem> = vec![];
+ // TODO: also handle reposts
+ let rows = {
+ let mut stmt = srv.atp_db
+ .conn
+ .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?;
+ let mut sql_rows = stmt.query(params!(did.to_string()))?;
+ let mut rows: Vec<FeedRow> = vec![];
+ while let Some(sql_row) = sql_rows.next()? {
+ let row = feed_row(sql_row)?;
+ rows.push(row);
+ }
+ rows
+ };
+ for row in rows {
+ feed.push(feed_row_to_item(srv, row)?);
+ }
+ Ok(app_bsky::GenericFeed { feed })
+}
+
+// TODO: this is a partial implementation
+// TODO: should maybe have this take a did and tid instead of a aturi?
+pub fn bsky_get_thread(
+ srv: &mut AtpService,
+ uri: &AtUri,
+ _depth: Option<u64>,
+) -> Result<app_bsky::PostThread> {
+ // parse the URI
+ let did = match uri.repository {
+ DidOrHost::Did(ref did_type, ref did_body) => {
+ Did::from_str(&format!("did:{}:{}", did_type, did_body))?
+ }
+ _ => Err(anyhow!("expected a DID, not handle, in uri: {}", uri))?,
+ };
+ if uri.collection != Some("app.bsky.feed.post".to_string()) {
+ Err(anyhow!("expected a post collection in uri: {}", uri))?;
+ };
+ let tid = match uri.record {
+ Some(ref tid) => Tid::from_str(tid)?,
+ _ => Err(anyhow!("expected a record in uri: {}", uri))?,
+ };
+
+ // post itself, as a app_bsky::FeedItem
+ let post_items = {
+ let mut stmt = srv.atp_db
+ .conn
+ .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 AND bsky_post.tid = ?2")?;
+ let mut sql_rows = stmt.query(params!(did.to_string(), tid.to_string()))?;
+ let mut rows: Vec<FeedRow> = vec![];
+ while let Some(sql_row) = sql_rows.next()? {
+ let row = feed_row(sql_row)?;
+ rows.push(row);
+ }
+ rows
+ };
+ if post_items.is_empty() {
+ Err(XrpcError::NotFound("post not found".to_string()))?;
+ };
+ let post_item = feed_row_to_item(srv, post_items.into_iter().next().unwrap())?;
+
+ // TODO: any parent
+ let parent = None;
+
+ // any children
+ let mut children = vec![];
+ let rows = {
+ let mut stmt = srv.atp_db
+ .conn
+ .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.reply_to_parent_uri = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?;
+ let mut sql_rows = stmt.query(params!(uri.to_string()))?;
+ let mut rows: Vec<FeedRow> = vec![];
+ while let Some(sql_row) = sql_rows.next()? {
+ let row = feed_row(sql_row)?;
+ rows.push(row);
+ }
+ rows
+ };
+ for row in rows {
+ let item = feed_row_to_item(srv, row)?;
+ children.push(app_bsky::ThreadItem {
+ uri: item.uri,
+ cid: item.cid,
+ author: item.author,
+ record: item.record,
+ embed: item.embed,
+ // don't want a loop here
+ parent: None,
+ replyCount: item.replyCount,
+ // only going to depth of one here
+ replies: None,
+ upvoteCount: item.upvoteCount,
+ downvoteCount: 0,
+ repostCount: item.repostCount,
+ indexedAt: item.indexedAt,
+ myState: None,
+ });
+ }
+
+ let post = app_bsky::ThreadItem {
+ uri: post_item.uri,
+ cid: post_item.cid,
+ author: post_item.author,
+ record: post_item.record,
+ embed: post_item.embed,
+ parent,
+ replyCount: post_item.replyCount,
+ replies: Some(children),
+ upvoteCount: post_item.upvoteCount,
+ downvoteCount: 0,
+ repostCount: post_item.repostCount,
+ indexedAt: post_item.indexedAt,
+ myState: None,
+ };
+ Ok(app_bsky::PostThread { thread: post })
+}
+
+#[test]
+fn test_bsky_profile() {
+ use crate::{create_account, created_at_now};
+ use adenosine::com_atproto;
+ use libipld::ipld;
+
+ let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap();
+ let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap();
+
+ let mut srv = AtpService::new_ephemeral().unwrap();
+ let req = com_atproto::AccountRequest {
+ email: "test@bogus.com".to_string(),
+ handle: "handle.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ let did = Did::from_str(&session.did).unwrap();
+ let profile = bsky_get_profile(&mut srv, &did).unwrap();
+ assert_eq!(profile.did, session.did);
+ assert_eq!(profile.handle, req.handle);
+ assert_eq!(profile.displayName, None);
+ assert_eq!(profile.description, None);
+ assert_eq!(profile.followersCount, 0);
+ assert_eq!(profile.followsCount, 0);
+ assert_eq!(profile.postsCount, 0);
+
+ let record = app_bsky::ProfileRecord {
+ displayName: "Test Name".to_string(),
+ description: Some("short description".to_string()),
+ };
+ bsky_update_profile(&mut srv, &did, record.clone()).unwrap();
+ let profile = bsky_get_profile(&mut srv, &did).unwrap();
+ assert_eq!(profile.displayName, Some(record.displayName));
+ assert_eq!(profile.description, record.description);
+
+ let record = app_bsky::ProfileRecord {
+ displayName: "New Test Name".to_string(),
+ description: Some("longer description".to_string()),
+ };
+ bsky_update_profile(&mut srv, &did, record.clone()).unwrap();
+ let profile = bsky_get_profile(&mut srv, &did).unwrap();
+ assert_eq!(profile.displayName, Some(record.displayName));
+ assert_eq!(profile.description, record.description);
+
+ let mutations = vec![
+ Mutation::Create(
+ follow_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"did": session.did}, "createdAt": created_at_now()}),
+ ),
+ Mutation::Create(
+ follow_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"did": "did:web:external.domain"}, "createdAt": created_at_now()}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"text": "first post"}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"text": "second post"}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"text": "third post"}),
+ ),
+ ];
+ srv.repo
+ .mutate_repo(&did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &did, mutations).unwrap();
+
+ let profile = bsky_get_profile(&mut srv, &did).unwrap();
+ assert_eq!(profile.followersCount, 1);
+ assert_eq!(profile.followsCount, 2);
+ assert_eq!(profile.postsCount, 3);
+}
+
+#[test]
+fn test_bsky_feeds() {
+ // TODO: test that displayName comes through in feeds and timelines (it does not currently)
+ use crate::{create_account, created_at_now};
+ use adenosine::com_atproto;
+ use libipld::ipld;
+
+ let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap();
+ let like_nsid = Nsid::from_str("app.bsky.feed.like").unwrap();
+ let repost_nsid = Nsid::from_str("app.bsky.feed.repost").unwrap();
+ let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap();
+
+ let mut srv = AtpService::new_ephemeral().unwrap();
+ let alice_did = {
+ let req = com_atproto::AccountRequest {
+ email: "alice@bogus.com".to_string(),
+ handle: "alice.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ Did::from_str(&session.did).unwrap()
+ };
+ let bob_did = {
+ let req = com_atproto::AccountRequest {
+ email: "bob@bogus.com".to_string(),
+ handle: "bob.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ Did::from_str(&session.did).unwrap()
+ };
+ let carol_did = {
+ let req = com_atproto::AccountRequest {
+ email: "carol@bogus.com".to_string(),
+ handle: "carol.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ Did::from_str(&session.did).unwrap()
+ };
+
+ // all feeds and timelines should be empty
+ let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap();
+ let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap();
+ assert!(alice_feed.feed.is_empty());
+ assert!(alice_timeline.feed.is_empty());
+ let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap();
+ let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap();
+ assert!(bob_feed.feed.is_empty());
+ assert!(bob_timeline.feed.is_empty());
+ let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap();
+ let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap();
+ assert!(carol_feed.feed.is_empty());
+ assert!(carol_timeline.feed.is_empty());
+
+ // alice does some posts
+ let alice_post1_tid = srv.tid_gen.next_tid();
+ let alice_post2_tid = srv.tid_gen.next_tid();
+ let alice_post3_tid = srv.tid_gen.next_tid();
+ assert!(alice_post1_tid < alice_post2_tid && alice_post2_tid < alice_post3_tid);
+ let mutations = vec![
+ Mutation::Create(
+ post_nsid.clone(),
+ alice_post1_tid.clone(),
+ ipld!({"text": "alice first post"}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ alice_post2_tid.clone(),
+ ipld!({"text": "alice second post"}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ alice_post3_tid.clone(),
+ ipld!({"text": "alice third post"}),
+ ),
+ ];
+ srv.repo
+ .mutate_repo(&alice_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap();
+
+ // bob follows alice, likes first post, reposts second, replies third
+ let alice_post3_uri = format!(
+ "at://{}/{}/{}",
+ alice_did.to_string(),
+ post_nsid.to_string(),
+ alice_post3_tid.to_string()
+ );
+ let mutations = vec![
+ Mutation::Create(
+ follow_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"did": alice_did.to_string()}, "createdAt": created_at_now()}),
+ ),
+ Mutation::Create(
+ like_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string())}, "createdAt": created_at_now()}),
+ ),
+ Mutation::Create(
+ repost_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post2_tid.to_string())}, "createdAt": created_at_now()}),
+ ),
+ Mutation::Create(
+ post_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"text": "bob comment on alice post3", "reply": {"parent": {"uri": alice_post3_uri.clone()}, "root": {"uri": alice_post3_uri.clone()}}}),
+ ),
+ ];
+ srv.repo
+ .mutate_repo(&bob_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap();
+
+ // carol follows bob
+ let mutations = vec![Mutation::Create(
+ follow_nsid.clone(),
+ srv.tid_gen.next_tid(),
+ ipld!({"subject": {"did": bob_did.to_string()}, "createdAt": created_at_now()}),
+ )];
+ srv.repo
+ .mutate_repo(&bob_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &carol_did, mutations).unwrap();
+
+ // test alice profile: counts should be updated
+ let alice_profile = bsky_get_profile(&mut srv, &alice_did).unwrap();
+ assert_eq!(alice_profile.followersCount, 1);
+ assert_eq!(alice_profile.followsCount, 0);
+ assert_eq!(alice_profile.postsCount, 3);
+
+ // test alice timeline: still empty (?)
+ let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap();
+ println!("{:?}", alice_timeline);
+ assert!(alice_timeline.feed.is_empty());
+
+ // test alice feed: should have 3 posts, with correct counts
+ let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap();
+ assert_eq!(alice_feed.feed.len(), 3);
+
+ assert_eq!(
+ alice_feed.feed[2].uri,
+ format!(
+ "at://{}/{}/{}",
+ alice_did.to_string(),
+ post_nsid.to_string(),
+ alice_post1_tid.to_string()
+ )
+ );
+ // TODO: CID
+ assert_eq!(alice_feed.feed[2].author.did, alice_did.to_string());
+ assert_eq!(alice_feed.feed[2].author.handle, "alice.test");
+ assert_eq!(alice_feed.feed[2].repostedBy, None);
+ assert_eq!(
+ alice_feed.feed[2].record["text"].as_str().unwrap(),
+ "alice first post"
+ );
+ assert_eq!(alice_feed.feed[2].embed, None);
+ assert_eq!(alice_feed.feed[2].replyCount, 0);
+ assert_eq!(alice_feed.feed[2].repostCount, 0);
+ assert_eq!(alice_feed.feed[2].upvoteCount, 1);
+ assert_eq!(alice_feed.feed[2].downvoteCount, 0);
+
+ assert_eq!(alice_feed.feed[1].author.did, alice_did.to_string());
+ assert_eq!(alice_feed.feed[1].replyCount, 0);
+ assert_eq!(alice_feed.feed[1].repostCount, 1);
+ assert_eq!(alice_feed.feed[1].upvoteCount, 0);
+
+ assert_eq!(alice_feed.feed[0].author.did, alice_did.to_string());
+ assert_eq!(alice_feed.feed[0].replyCount, 1);
+ assert_eq!(alice_feed.feed[0].repostCount, 0);
+ assert_eq!(alice_feed.feed[0].upvoteCount, 0);
+
+ // test bob timeline: should include alice posts
+ let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap();
+ println!("BOB TIMELINE ======");
+ for item in bob_timeline.feed.iter() {
+ println!("{:?}", item);
+ }
+ assert_eq!(bob_timeline.feed.len(), 3);
+ assert_eq!(
+ bob_timeline.feed[2].uri,
+ format!(
+ "at://{}/{}/{}",
+ alice_did.to_string(),
+ post_nsid.to_string(),
+ alice_post1_tid.to_string()
+ )
+ );
+ // TODO: CID
+ assert_eq!(bob_timeline.feed[2].author.did, alice_did.to_string());
+ assert_eq!(bob_timeline.feed[2].author.handle, "alice.test");
+
+ // test bob feed: should include repost and reply
+ let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap();
+ assert_eq!(bob_feed.feed.len(), 1);
+ // TODO: handle reposts
+ /*
+ assert_eq!(bob_feed.feed.len(), 2);
+ assert_eq!(bob_feed.feed[1].uri, format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string()));
+ // TODO: CID
+ assert_eq!(bob_feed.feed[1].author.did, alice_did.to_string());
+ assert_eq!(bob_feed.feed[1].author.handle, "alice.test");
+ assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().did, bob_did.to_string());
+ assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().handle, "bob.test");
+ // TODO: "is a repost" (check record?)
+ */
+
+ assert_eq!(bob_feed.feed[0].author.did, bob_did.to_string());
+ assert_eq!(bob_feed.feed[0].author.handle, "bob.test");
+
+ // test carol timeline: should include bob's repost and reply
+ let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap();
+ // TODO: handle re-posts (+1 here)
+ assert_eq!(carol_timeline.feed.len(), 1);
+ // TODO: details
+
+ // test carol feed: still empty
+ let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap();
+ assert!(carol_feed.feed.is_empty());
+}
+
+#[test]
+fn test_bsky_thread() {
+ use crate::create_account;
+ use adenosine::com_atproto;
+ use libipld::ipld;
+
+ let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap();
+
+ let mut srv = AtpService::new_ephemeral().unwrap();
+ let alice_did = {
+ let req = com_atproto::AccountRequest {
+ email: "alice@bogus.com".to_string(),
+ handle: "alice.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ Did::from_str(&session.did).unwrap()
+ };
+ let bob_did = {
+ let req = com_atproto::AccountRequest {
+ email: "bob@bogus.com".to_string(),
+ handle: "bob.test".to_string(),
+ password: "bogus".to_string(),
+ inviteCode: None,
+ recoveryKey: None,
+ };
+ let session = create_account(&mut srv, &req, true).unwrap();
+ Did::from_str(&session.did).unwrap()
+ };
+
+ // alice does a post
+ let alice_post1_tid = srv.tid_gen.next_tid();
+ let mutations = vec![Mutation::Create(
+ post_nsid.clone(),
+ alice_post1_tid.clone(),
+ ipld!({"text": "alice first post"}),
+ )];
+ srv.repo
+ .mutate_repo(&alice_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap();
+ let alice_post1_uri = format!(
+ "at://{}/{}/{}",
+ alice_did.to_string(),
+ post_nsid.to_string(),
+ alice_post1_tid.to_string()
+ );
+
+ // bob likes and replies first post
+ let bob_post1_tid = srv.tid_gen.next_tid();
+ let mutations = vec![Mutation::Create(
+ post_nsid.clone(),
+ bob_post1_tid.clone(),
+ ipld!({"text": "bob comment on alice post1", "reply": {"parent": {"uri": alice_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}),
+ )];
+ srv.repo
+ .mutate_repo(&bob_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap();
+ let bob_post1_uri = format!(
+ "at://{}/{}/{}",
+ bob_did.to_string(),
+ post_nsid.to_string(),
+ bob_post1_tid.to_string()
+ );
+
+ // alice replies to bob reply
+ let alice_post2_tid = srv.tid_gen.next_tid();
+ let mutations = vec![Mutation::Create(
+ post_nsid.clone(),
+ alice_post2_tid.clone(),
+ ipld!({"text": "alice second post, replying to bob comment", "reply": {"parent": {"uri": bob_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}),
+ )];
+ srv.repo
+ .mutate_repo(&alice_did, &mutations, &srv.pds_keypair)
+ .unwrap();
+ bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap();
+ let _alice_post2_uri = format!(
+ "at://{}/{}/{}",
+ alice_did.to_string(),
+ post_nsid.to_string(),
+ alice_post2_tid.to_string()
+ );
+
+ // get thread from bob's post
+ // TODO: should have both parent and children
+ let post = bsky_get_thread(&mut srv, &AtUri::from_str(&bob_post1_uri).unwrap(), None)
+ .unwrap()
+ .thread;
+ assert_eq!(post.author.did, bob_did.to_string());
+ assert_eq!(post.author.handle, "bob.test".to_string());
+ assert_eq!(post.embed, None);
+ assert_eq!(post.replyCount, 1);
+ assert_eq!(post.repostCount, 0);
+ assert_eq!(post.upvoteCount, 0);
+ assert_eq!(post.replies.as_ref().unwrap().len(), 1);
+
+ let post_replies = post.replies.unwrap();
+ assert_eq!(post_replies[0].author.did, alice_did.to_string());
+ // TODO: root URI, etc
+}