BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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}