BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 729 lines 26 kB view raw
1use super::error::{AppError, Result}; 2use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, StrongRefInput}; 3use super::state::AppState; 4use rusqlite::{params, Connection, OptionalExtension}; 5use serde::{Deserialize, Serialize}; 6use tauri_plugin_log::log; 7use uuid::Uuid; 8 9#[derive(Debug, Clone, Serialize)] 10#[serde(rename_all = "camelCase")] 11pub struct Draft { 12 pub id: String, 13 pub account_did: String, 14 pub text: String, 15 pub reply_parent_uri: Option<String>, 16 pub reply_parent_cid: Option<String>, 17 pub reply_root_uri: Option<String>, 18 pub reply_root_cid: Option<String>, 19 pub quote_uri: Option<String>, 20 pub quote_cid: Option<String>, 21 pub title: Option<String>, 22 pub created_at: String, 23 pub updated_at: String, 24} 25 26#[derive(Debug, Deserialize)] 27#[serde(rename_all = "camelCase")] 28pub struct DraftInput { 29 pub id: Option<String>, 30 pub text: String, 31 pub reply_parent_uri: Option<String>, 32 pub reply_parent_cid: Option<String>, 33 pub reply_root_uri: Option<String>, 34 pub reply_root_cid: Option<String>, 35 pub quote_uri: Option<String>, 36 pub quote_cid: Option<String>, 37 pub title: Option<String>, 38} 39 40fn row_to_draft(row: &rusqlite::Row<'_>) -> rusqlite::Result<Draft> { 41 Ok(Draft { 42 id: row.get(0)?, 43 account_did: row.get(1)?, 44 text: row.get(2)?, 45 reply_parent_uri: row.get(3)?, 46 reply_parent_cid: row.get(4)?, 47 reply_root_uri: row.get(5)?, 48 reply_root_cid: row.get(6)?, 49 quote_uri: row.get(7)?, 50 quote_cid: row.get(8)?, 51 title: row.get(9)?, 52 created_at: row.get(10)?, 53 updated_at: row.get(11)?, 54 }) 55} 56 57fn db_list_drafts(conn: &Connection, account_did: &str) -> Result<Vec<Draft>> { 58 let mut stmt = conn.prepare( 59 "SELECT id, account_did, text, reply_parent_uri, reply_parent_cid, 60 reply_root_uri, reply_root_cid, quote_uri, quote_cid, 61 title, created_at, updated_at 62 FROM drafts 63 WHERE account_did = ?1 64 ORDER BY updated_at DESC", 65 )?; 66 67 let rows = stmt.query_map(params![account_did], row_to_draft)?; 68 69 let mut drafts = Vec::new(); 70 for row in rows { 71 drafts.push(row?); 72 } 73 Ok(drafts) 74} 75 76fn db_get_draft(conn: &Connection, id: &str) -> Result<Draft> { 77 conn.query_row( 78 "SELECT id, account_did, text, reply_parent_uri, reply_parent_cid, 79 reply_root_uri, reply_root_cid, quote_uri, quote_cid, 80 title, created_at, updated_at 81 FROM drafts 82 WHERE id = ?1", 83 params![id], 84 row_to_draft, 85 ) 86 .optional()? 87 .ok_or_else(|| AppError::validation(format!("draft {id} not found"))) 88} 89 90fn db_get_draft_for_account(conn: &Connection, id: &str, account_did: &str) -> Result<Draft> { 91 let draft = db_get_draft(conn, id)?; 92 if draft.account_did != account_did { 93 return Err(AppError::validation("draft does not belong to the active account")); 94 } 95 96 Ok(draft) 97} 98 99fn db_save_draft(conn: &Connection, account_did: &str, input: &DraftInput) -> Result<Draft> { 100 let id = match &input.id { 101 Some(existing_id) => { 102 let exists: bool = conn 103 .query_row( 104 "SELECT COUNT(*) FROM drafts WHERE id = ?1 AND account_did = ?2", 105 params![existing_id, account_did], 106 |row| row.get::<_, i64>(0), 107 ) 108 .map(|count| count > 0) 109 .unwrap_or(false); 110 111 if exists { 112 conn.execute( 113 "UPDATE drafts 114 SET text = ?1, reply_parent_uri = ?2, reply_parent_cid = ?3, 115 reply_root_uri = ?4, reply_root_cid = ?5, 116 quote_uri = ?6, quote_cid = ?7, title = ?8, 117 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 118 WHERE id = ?9 AND account_did = ?10", 119 params![ 120 input.text, 121 input.reply_parent_uri, 122 input.reply_parent_cid, 123 input.reply_root_uri, 124 input.reply_root_cid, 125 input.quote_uri, 126 input.quote_cid, 127 input.title, 128 existing_id, 129 account_did, 130 ], 131 )?; 132 existing_id.clone() 133 } else { 134 db_insert_draft(conn, account_did, input)? 135 } 136 } 137 None => db_insert_draft(conn, account_did, input)?, 138 }; 139 140 db_get_draft(conn, &id) 141} 142 143fn db_insert_draft(conn: &Connection, account_did: &str, input: &DraftInput) -> Result<String> { 144 let id = Uuid::new_v4().to_string(); 145 conn.execute( 146 "INSERT INTO drafts (id, account_did, text, reply_parent_uri, reply_parent_cid, 147 reply_root_uri, reply_root_cid, quote_uri, quote_cid, 148 title, created_at, updated_at) 149 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 150 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 151 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", 152 params![ 153 id, 154 account_did, 155 input.text, 156 input.reply_parent_uri, 157 input.reply_parent_cid, 158 input.reply_root_uri, 159 input.reply_root_cid, 160 input.quote_uri, 161 input.quote_cid, 162 input.title, 163 ], 164 )?; 165 Ok(id) 166} 167 168fn db_delete_draft(conn: &Connection, id: &str) -> Result<()> { 169 let affected = conn.execute("DELETE FROM drafts WHERE id = ?1", params![id])?; 170 if affected == 0 { 171 log::warn!("delete_draft: no draft found with id {id}"); 172 } 173 Ok(()) 174} 175 176fn db_delete_draft_for_account(conn: &Connection, id: &str, account_did: &str) -> Result<()> { 177 let owner = conn 178 .query_row("SELECT account_did FROM drafts WHERE id = ?1", params![id], |row| { 179 row.get::<_, String>(0) 180 }) 181 .optional()?; 182 183 match owner { 184 None => { 185 log::warn!("delete_draft: no draft found with id {id}"); 186 Ok(()) 187 } 188 Some(owner_did) => { 189 if owner_did != account_did { 190 return Err(AppError::validation("draft does not belong to the active account")); 191 } 192 193 db_delete_draft(conn, id) 194 } 195 } 196} 197 198fn active_account_did(state: &AppState) -> Result<String> { 199 state 200 .active_session 201 .read() 202 .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))? 203 .as_ref() 204 .ok_or_else(|| AppError::validation("no active account")) 205 .map(|session| session.did.clone()) 206} 207 208pub fn list_drafts(account_did: &str, state: &AppState) -> Result<Vec<Draft>> { 209 let active_did = active_account_did(state)?; 210 if account_did != active_did { 211 return Err(AppError::validation("account does not match the active account")); 212 } 213 214 let conn = state.auth_store.lock_connection()?; 215 db_list_drafts(&conn, &active_did) 216} 217 218pub fn get_draft(id: &str, state: &AppState) -> Result<Draft> { 219 let active_did = active_account_did(state)?; 220 let conn = state.auth_store.lock_connection()?; 221 db_get_draft_for_account(&conn, id, &active_did) 222} 223 224pub fn save_draft(input: &DraftInput, state: &AppState) -> Result<Draft> { 225 let account_did = active_account_did(state)?; 226 227 let conn = state.auth_store.lock_connection()?; 228 db_save_draft(&conn, &account_did, input) 229} 230 231pub fn delete_draft(id: &str, state: &AppState) -> Result<()> { 232 let active_did = active_account_did(state)?; 233 let conn = state.auth_store.lock_connection()?; 234 db_delete_draft_for_account(&conn, id, &active_did) 235} 236 237pub async fn submit_draft(id: String, state: &AppState) -> Result<CreateRecordResult> { 238 let account_did = active_account_did(state)?; 239 240 let draft = { 241 let conn = state.auth_store.lock_connection()?; 242 db_get_draft_for_account(&conn, &id, &account_did)? 243 }; 244 245 let reply_to = build_reply_ref(&draft)?; 246 let embed = build_embed(&draft)?; 247 248 let result = feed::create_post(draft.text, reply_to, embed, state).await?; 249 250 { 251 let conn = state.auth_store.lock_connection()?; 252 if let Err(error) = db_delete_draft(&conn, &id) { 253 log::error!("submit_draft: failed to delete draft {id} after successful post: {error}"); 254 } 255 } 256 257 Ok(result) 258} 259 260fn build_reply_ref(draft: &Draft) -> Result<Option<ReplyRefInput>> { 261 match ( 262 &draft.reply_parent_uri, 263 &draft.reply_parent_cid, 264 &draft.reply_root_uri, 265 &draft.reply_root_cid, 266 ) { 267 (Some(parent_uri), Some(parent_cid), Some(root_uri), Some(root_cid)) => Ok(Some(ReplyRefInput { 268 parent: StrongRefInput { uri: parent_uri.clone(), cid: parent_cid.clone() }, 269 root: StrongRefInput { uri: root_uri.clone(), cid: root_cid.clone() }, 270 })), 271 (None, None, None, None) => Ok(None), 272 _ => Err(AppError::validation( 273 "draft has incomplete reply reference — all four reply fields must be set together", 274 )), 275 } 276} 277 278fn build_embed(draft: &Draft) -> Result<Option<EmbedInput>> { 279 match (&draft.quote_uri, &draft.quote_cid) { 280 (Some(uri), Some(cid)) => Ok(Some(EmbedInput::Record { 281 record: StrongRefInput { uri: uri.clone(), cid: cid.clone() }, 282 })), 283 (None, None) => Ok(None), 284 _ => Err(AppError::validation( 285 "draft has incomplete quote reference — both quote_uri and quote_cid must be set together", 286 )), 287 } 288} 289 290#[cfg(test)] 291mod tests { 292 use super::*; 293 use rusqlite::Connection; 294 295 fn draft_db() -> Connection { 296 let conn = Connection::open_in_memory().expect("in-memory db should open"); 297 conn.execute_batch(include_str!("migrations/011_drafts.sql")) 298 .expect("drafts migration should apply"); 299 conn 300 } 301 302 fn insert_draft(conn: &Connection, account_did: &str, text: &str) -> Draft { 303 let input = DraftInput { 304 id: None, 305 text: text.to_string(), 306 reply_parent_uri: None, 307 reply_parent_cid: None, 308 reply_root_uri: None, 309 reply_root_cid: None, 310 quote_uri: None, 311 quote_cid: None, 312 title: None, 313 }; 314 db_save_draft(conn, account_did, &input).expect("insert should succeed") 315 } 316 317 #[test] 318 fn migration_creates_drafts_table() { 319 let conn = draft_db(); 320 let count: i64 = conn 321 .query_row("SELECT COUNT(*) FROM drafts", [], |row| row.get(0)) 322 .expect("should query empty drafts table"); 323 assert_eq!(count, 0); 324 } 325 326 #[test] 327 fn save_draft_inserts_with_generated_uuid_when_no_id() { 328 let conn = draft_db(); 329 let input = DraftInput { 330 id: None, 331 text: "hello world".to_string(), 332 reply_parent_uri: None, 333 reply_parent_cid: None, 334 reply_root_uri: None, 335 reply_root_cid: None, 336 quote_uri: None, 337 quote_cid: None, 338 title: Some("my draft".to_string()), 339 }; 340 341 let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 342 343 assert!(!draft.id.is_empty()); 344 assert_eq!(draft.account_did, "did:plc:alice"); 345 assert_eq!(draft.text, "hello world"); 346 assert_eq!(draft.title, Some("my draft".to_string())); 347 assert!(draft.reply_parent_uri.is_none()); 348 assert!(draft.quote_uri.is_none()); 349 assert!(!draft.created_at.is_empty()); 350 assert!(!draft.updated_at.is_empty()); 351 } 352 353 #[test] 354 fn save_draft_updates_existing_when_id_matches() { 355 let conn = draft_db(); 356 let original = insert_draft(&conn, "did:plc:alice", "original text"); 357 358 let input = DraftInput { 359 id: Some(original.id.clone()), 360 text: "updated text".to_string(), 361 reply_parent_uri: None, 362 reply_parent_cid: None, 363 reply_root_uri: None, 364 reply_root_cid: None, 365 quote_uri: None, 366 quote_cid: None, 367 title: Some("updated title".to_string()), 368 }; 369 370 let updated = db_save_draft(&conn, "did:plc:alice", &input).expect("update should succeed"); 371 372 assert_eq!(updated.id, original.id, "id should remain the same after update"); 373 assert_eq!(updated.text, "updated text"); 374 assert_eq!(updated.title, Some("updated title".to_string())); 375 assert_eq!( 376 updated.created_at, original.created_at, 377 "created_at should not change on update" 378 ); 379 380 let count: i64 = conn 381 .query_row("SELECT COUNT(*) FROM drafts", [], |row| row.get(0)) 382 .expect("count should succeed"); 383 assert_eq!(count, 1, "update should not create a new row"); 384 } 385 386 #[test] 387 fn save_draft_inserts_new_when_id_not_found_in_db() { 388 let conn = draft_db(); 389 390 let input = DraftInput { 391 id: Some("non-existent-id".to_string()), 392 text: "orphan draft".to_string(), 393 reply_parent_uri: None, 394 reply_parent_cid: None, 395 reply_root_uri: None, 396 reply_root_cid: None, 397 quote_uri: None, 398 quote_cid: None, 399 title: None, 400 }; 401 402 let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 403 404 assert_ne!( 405 draft.id, "non-existent-id", 406 "a new UUID should be generated when the provided id does not exist" 407 ); 408 assert_eq!(draft.text, "orphan draft"); 409 } 410 411 #[test] 412 fn save_draft_cannot_update_another_accounts_draft() { 413 let conn = draft_db(); 414 let alice_draft = insert_draft(&conn, "did:plc:alice", "alice's post"); 415 416 let input = DraftInput { 417 id: Some(alice_draft.id.clone()), 418 text: "bob's takeover".to_string(), 419 reply_parent_uri: None, 420 reply_parent_cid: None, 421 reply_root_uri: None, 422 reply_root_cid: None, 423 quote_uri: None, 424 quote_cid: None, 425 title: None, 426 }; 427 428 let saved = db_save_draft(&conn, "did:plc:bob", &input).expect("save should succeed"); 429 assert_ne!(saved.id, alice_draft.id, "cross-account update must not occur"); 430 431 let alice_unchanged = db_get_draft(&conn, &alice_draft.id).expect("alice's draft should still exist"); 432 assert_eq!( 433 alice_unchanged.text, "alice's post", 434 "alice's draft must remain unchanged" 435 ); 436 } 437 438 #[test] 439 fn list_drafts_returns_only_active_account_drafts() { 440 let conn = draft_db(); 441 insert_draft(&conn, "did:plc:alice", "alice draft 1"); 442 insert_draft(&conn, "did:plc:alice", "alice draft 2"); 443 insert_draft(&conn, "did:plc:bob", "bob draft"); 444 445 let alice_drafts = db_list_drafts(&conn, "did:plc:alice").expect("list should succeed"); 446 assert_eq!(alice_drafts.len(), 2); 447 assert!(alice_drafts.iter().all(|d| d.account_did == "did:plc:alice")); 448 449 let bob_drafts = db_list_drafts(&conn, "did:plc:bob").expect("list should succeed"); 450 assert_eq!(bob_drafts.len(), 1); 451 } 452 453 #[test] 454 fn list_drafts_returns_empty_for_unknown_account() { 455 let conn = draft_db(); 456 let drafts = db_list_drafts(&conn, "did:plc:ghost").expect("list should succeed"); 457 assert!(drafts.is_empty()); 458 } 459 460 #[test] 461 fn list_drafts_ordered_by_updated_at_desc() { 462 let conn = draft_db(); 463 464 conn.execute( 465 "INSERT INTO drafts (id, account_did, text, created_at, updated_at) 466 VALUES ('draft-old', 'did:plc:alice', 'old', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')", 467 [], 468 ) 469 .expect("insert old draft"); 470 471 conn.execute( 472 "INSERT INTO drafts (id, account_did, text, created_at, updated_at) 473 VALUES ('draft-new', 'did:plc:alice', 'new', '2024-01-02T00:00:00.000Z', '2024-01-02T00:00:00.000Z')", 474 [], 475 ) 476 .expect("insert new draft"); 477 478 let drafts = db_list_drafts(&conn, "did:plc:alice").expect("list should succeed"); 479 assert_eq!(drafts.len(), 2); 480 assert_eq!(drafts[0].id, "draft-new", "most recently updated draft should be first"); 481 assert_eq!(drafts[1].id, "draft-old"); 482 } 483 484 #[test] 485 fn get_draft_returns_correct_draft() { 486 let conn = draft_db(); 487 let created = insert_draft(&conn, "did:plc:alice", "get me"); 488 489 let fetched = db_get_draft(&conn, &created.id).expect("get should succeed"); 490 assert_eq!(fetched.id, created.id); 491 assert_eq!(fetched.text, "get me"); 492 } 493 494 #[test] 495 fn get_draft_errors_for_missing_id() { 496 let conn = draft_db(); 497 let result = db_get_draft(&conn, "does-not-exist"); 498 assert!(result.is_err(), "get_draft should return an error for missing id"); 499 } 500 501 #[test] 502 fn get_draft_for_account_rejects_foreign_draft() { 503 let conn = draft_db(); 504 let draft = insert_draft(&conn, "did:plc:alice", "alice secret"); 505 506 let result = db_get_draft_for_account(&conn, &draft.id, "did:plc:bob"); 507 assert!(result.is_err(), "should reject loading another account's draft"); 508 } 509 510 #[test] 511 fn delete_draft_removes_draft() { 512 let conn = draft_db(); 513 let draft = insert_draft(&conn, "did:plc:alice", "to be deleted"); 514 515 db_delete_draft(&conn, &draft.id).expect("delete should succeed"); 516 517 let result = db_get_draft(&conn, &draft.id); 518 assert!(result.is_err(), "draft should be gone after delete"); 519 } 520 521 #[test] 522 fn delete_draft_is_idempotent_for_missing_id() { 523 let conn = draft_db(); 524 db_delete_draft(&conn, "ghost-id").expect("delete of missing draft should not error"); 525 } 526 527 #[test] 528 fn delete_draft_for_account_rejects_foreign_draft() { 529 let conn = draft_db(); 530 let draft = insert_draft(&conn, "did:plc:alice", "alice only"); 531 532 let delete_result = db_delete_draft_for_account(&conn, &draft.id, "did:plc:bob"); 533 assert!(delete_result.is_err(), "should reject deleting another account's draft"); 534 535 let still_exists = db_get_draft(&conn, &draft.id).expect("draft should remain after rejected delete"); 536 assert_eq!(still_exists.account_did, "did:plc:alice"); 537 } 538 539 #[test] 540 fn delete_draft_for_account_is_idempotent_for_missing_id() { 541 let conn = draft_db(); 542 db_delete_draft_for_account(&conn, "ghost-id", "did:plc:alice") 543 .expect("delete of missing draft should not error"); 544 } 545 546 #[test] 547 fn save_draft_preserves_reply_fields() { 548 let conn = draft_db(); 549 let input = DraftInput { 550 id: None, 551 text: "a reply".to_string(), 552 reply_parent_uri: Some("at://did:plc:parent/app.bsky.feed.post/abc".to_string()), 553 reply_parent_cid: Some("bafyparent".to_string()), 554 reply_root_uri: Some("at://did:plc:root/app.bsky.feed.post/xyz".to_string()), 555 reply_root_cid: Some("bafyroot".to_string()), 556 quote_uri: None, 557 quote_cid: None, 558 title: None, 559 }; 560 561 let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 562 assert_eq!( 563 draft.reply_parent_uri.as_deref(), 564 Some("at://did:plc:parent/app.bsky.feed.post/abc") 565 ); 566 assert_eq!(draft.reply_parent_cid.as_deref(), Some("bafyparent")); 567 assert_eq!( 568 draft.reply_root_uri.as_deref(), 569 Some("at://did:plc:root/app.bsky.feed.post/xyz") 570 ); 571 assert_eq!(draft.reply_root_cid.as_deref(), Some("bafyroot")); 572 } 573 574 #[test] 575 fn save_draft_preserves_quote_fields() { 576 let conn = draft_db(); 577 let input = DraftInput { 578 id: None, 579 text: "quoting".to_string(), 580 reply_parent_uri: None, 581 reply_parent_cid: None, 582 reply_root_uri: None, 583 reply_root_cid: None, 584 quote_uri: Some("at://did:plc:quoted/app.bsky.feed.post/qrs".to_string()), 585 quote_cid: Some("bafyquote".to_string()), 586 title: None, 587 }; 588 589 let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 590 assert_eq!( 591 draft.quote_uri.as_deref(), 592 Some("at://did:plc:quoted/app.bsky.feed.post/qrs") 593 ); 594 assert_eq!(draft.quote_cid.as_deref(), Some("bafyquote")); 595 } 596 597 #[test] 598 fn build_reply_ref_returns_none_when_no_reply_fields() { 599 let draft = Draft { 600 id: "id".to_string(), 601 account_did: "did:plc:alice".to_string(), 602 text: "plain post".to_string(), 603 reply_parent_uri: None, 604 reply_parent_cid: None, 605 reply_root_uri: None, 606 reply_root_cid: None, 607 quote_uri: None, 608 quote_cid: None, 609 title: None, 610 created_at: "2024-01-01T00:00:00.000Z".to_string(), 611 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 612 }; 613 614 let result = build_reply_ref(&draft).expect("build_reply_ref should succeed"); 615 assert!(result.is_none()); 616 } 617 618 #[test] 619 fn build_reply_ref_returns_some_when_all_reply_fields_present() { 620 let draft = Draft { 621 id: "id".to_string(), 622 account_did: "did:plc:alice".to_string(), 623 text: "a reply".to_string(), 624 reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()), 625 reply_parent_cid: Some("bafy1".to_string()), 626 reply_root_uri: Some("at://did:plc:r/app.bsky.feed.post/2".to_string()), 627 reply_root_cid: Some("bafy2".to_string()), 628 quote_uri: None, 629 quote_cid: None, 630 title: None, 631 created_at: "2024-01-01T00:00:00.000Z".to_string(), 632 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 633 }; 634 635 let result = build_reply_ref(&draft).expect("build_reply_ref should succeed"); 636 assert!(result.is_some()); 637 let reply = result.unwrap(); 638 assert_eq!(reply.parent.uri, "at://did:plc:p/app.bsky.feed.post/1"); 639 assert_eq!(reply.root.cid, "bafy2"); 640 } 641 642 #[test] 643 fn build_reply_ref_errors_on_partial_reply_fields() { 644 let draft = Draft { 645 id: "id".to_string(), 646 account_did: "did:plc:alice".to_string(), 647 text: "broken reply".to_string(), 648 reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()), 649 reply_parent_cid: None, 650 reply_root_uri: None, 651 reply_root_cid: None, 652 quote_uri: None, 653 quote_cid: None, 654 title: None, 655 created_at: "2024-01-01T00:00:00.000Z".to_string(), 656 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 657 }; 658 659 assert!(build_reply_ref(&draft).is_err(), "partial reply fields should error"); 660 } 661 662 #[test] 663 fn build_embed_returns_none_when_no_quote_fields() { 664 let draft = Draft { 665 id: "id".to_string(), 666 account_did: "did:plc:alice".to_string(), 667 text: "plain".to_string(), 668 reply_parent_uri: None, 669 reply_parent_cid: None, 670 reply_root_uri: None, 671 reply_root_cid: None, 672 quote_uri: None, 673 quote_cid: None, 674 title: None, 675 created_at: "2024-01-01T00:00:00.000Z".to_string(), 676 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 677 }; 678 679 let result = build_embed(&draft).expect("build_embed should succeed"); 680 assert!(result.is_none()); 681 } 682 683 #[test] 684 fn build_embed_returns_record_embed_when_quote_fields_present() { 685 let draft = Draft { 686 id: "id".to_string(), 687 account_did: "did:plc:alice".to_string(), 688 text: "quoting".to_string(), 689 reply_parent_uri: None, 690 reply_parent_cid: None, 691 reply_root_uri: None, 692 reply_root_cid: None, 693 quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()), 694 quote_cid: Some("bafyq".to_string()), 695 title: None, 696 created_at: "2024-01-01T00:00:00.000Z".to_string(), 697 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 698 }; 699 700 let result = build_embed(&draft).expect("build_embed should succeed"); 701 assert!(result.is_some()); 702 match result.unwrap() { 703 EmbedInput::Record { record } => { 704 assert_eq!(record.uri, "at://did:plc:q/app.bsky.feed.post/abc"); 705 assert_eq!(record.cid, "bafyq"); 706 } 707 } 708 } 709 710 #[test] 711 fn build_embed_errors_on_partial_quote_fields() { 712 let draft = Draft { 713 id: "id".to_string(), 714 account_did: "did:plc:alice".to_string(), 715 text: "broken quote".to_string(), 716 reply_parent_uri: None, 717 reply_parent_cid: None, 718 reply_root_uri: None, 719 reply_root_cid: None, 720 quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()), 721 quote_cid: None, 722 title: None, 723 created_at: "2024-01-01T00:00:00.000Z".to_string(), 724 updated_at: "2024-01-01T00:00:00.000Z".to_string(), 725 }; 726 727 assert!(build_embed(&draft).is_err(), "partial quote fields should error"); 728 } 729}