1use std::sync::Arc;
4
5use axum::extract::State;
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::content::{
10 serialize_blocks_for_storage, tweet_weighted_len, validate_thread_blocks, ThreadBlock,
11 MAX_TWEET_CHARS,
12};
13use tuitbot_core::storage::provenance::ProvenanceRef;
14use tuitbot_core::storage::{action_log, approval_queue, scheduled_content};
15use tuitbot_core::x_api::{XApiClient, XApiHttpClient};
16
17use crate::account::{require_mutate, AccountContext};
18use crate::error::ApiError;
19use crate::state::AppState;
20use crate::ws::{AccountWsEvent, WsEvent};
21
22use super::read_approval_mode;
23
24#[derive(Debug, Deserialize)]
26pub struct ThreadBlockRequest {
27 pub id: String,
29 pub text: String,
31 #[serde(default)]
33 pub media_paths: Vec<String>,
34 pub order: u32,
36}
37
38impl ThreadBlockRequest {
39 pub(crate) fn into_core(self) -> ThreadBlock {
41 ThreadBlock {
42 id: self.id,
43 text: self.text,
44 media_paths: self.media_paths,
45 order: self.order,
46 }
47 }
48}
49
50#[derive(Deserialize)]
52pub struct ComposeTweetRequest {
53 pub text: String,
55 pub scheduled_for: Option<String>,
57 #[serde(default)]
59 pub provenance: Option<Vec<ProvenanceRef>>,
60}
61
62pub async fn compose_tweet(
64 State(state): State<Arc<AppState>>,
65 ctx: AccountContext,
66 Json(body): Json<ComposeTweetRequest>,
67) -> Result<Json<Value>, ApiError> {
68 require_mutate(&ctx)?;
69
70 let text = body.text.trim();
71 if text.is_empty() {
72 return Err(ApiError::BadRequest("text is required".to_string()));
73 }
74
75 let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
77
78 if approval_mode {
79 let prov_input = build_provenance_input(body.provenance.as_deref());
80
81 let id = approval_queue::enqueue_with_provenance_for(
82 &state.db,
83 &ctx.account_id,
84 "tweet",
85 "", "", text,
88 "", "", 0.0,
91 "[]",
92 None,
93 None,
94 prov_input.as_ref(),
95 body.scheduled_for.as_deref(),
96 )
97 .await?;
98
99 let _ = state.event_tx.send(AccountWsEvent {
100 account_id: ctx.account_id.clone(),
101 event: WsEvent::ApprovalQueued {
102 id,
103 action_type: "tweet".to_string(),
104 content: text.to_string(),
105 media_paths: vec![],
106 },
107 });
108
109 Ok(Json(json!({
110 "status": "queued_for_approval",
111 "id": id,
112 "scheduled_for": body.scheduled_for,
113 })))
114 } else {
115 Ok(Json(json!({
117 "status": "accepted",
118 "text": text,
119 "scheduled_for": body.scheduled_for,
120 })))
121 }
122}
123
124#[derive(Deserialize)]
126pub struct ComposeThreadRequest {
127 pub tweets: Vec<String>,
129 pub scheduled_for: Option<String>,
131}
132
133pub async fn compose_thread(
135 State(state): State<Arc<AppState>>,
136 ctx: AccountContext,
137 Json(body): Json<ComposeThreadRequest>,
138) -> Result<Json<Value>, ApiError> {
139 require_mutate(&ctx)?;
140
141 if body.tweets.is_empty() {
142 return Err(ApiError::BadRequest(
143 "tweets array must not be empty".to_string(),
144 ));
145 }
146
147 let approval_mode = read_approval_mode(&state, &ctx.account_id).await?;
148 let combined = body.tweets.join("\n---\n");
149
150 if approval_mode {
151 let id = approval_queue::enqueue_with_context_for(
152 &state.db,
153 &ctx.account_id,
154 "thread",
155 "",
156 "",
157 &combined,
158 "",
159 "",
160 0.0,
161 "[]",
162 None,
163 None,
164 body.scheduled_for.as_deref(),
165 )
166 .await?;
167
168 let _ = state.event_tx.send(AccountWsEvent {
169 account_id: ctx.account_id.clone(),
170 event: WsEvent::ApprovalQueued {
171 id,
172 action_type: "thread".to_string(),
173 content: combined,
174 media_paths: vec![],
175 },
176 });
177
178 Ok(Json(json!({
179 "status": "queued_for_approval",
180 "id": id,
181 "scheduled_for": body.scheduled_for,
182 })))
183 } else {
184 Ok(Json(json!({
185 "status": "accepted",
186 "tweet_count": body.tweets.len(),
187 "scheduled_for": body.scheduled_for,
188 })))
189 }
190}
191
192#[derive(Deserialize)]
194pub struct ComposeRequest {
195 pub content_type: String,
197 pub content: String,
199 pub scheduled_for: Option<String>,
201 #[serde(default)]
203 pub media_paths: Option<Vec<String>>,
204 #[serde(default)]
206 pub blocks: Option<Vec<ThreadBlockRequest>>,
207 #[serde(default)]
209 pub provenance: Option<Vec<ProvenanceRef>>,
210}
211
212pub async fn compose(
214 State(state): State<Arc<AppState>>,
215 ctx: AccountContext,
216 Json(mut body): Json<ComposeRequest>,
217) -> Result<Json<Value>, ApiError> {
218 require_mutate(&ctx)?;
219
220 let blocks = body.blocks.take();
221
222 match body.content_type.as_str() {
223 "tweet" => compose_tweet_flow(&state, &ctx, &body).await,
224 "thread" => {
225 if let Some(blocks) = blocks {
226 compose_thread_blocks_flow(&state, &ctx, &body, blocks).await
227 } else {
228 compose_thread_legacy_flow(&state, &ctx, &body).await
229 }
230 }
231 _ => Err(ApiError::BadRequest(
232 "content_type must be 'tweet' or 'thread'".to_string(),
233 )),
234 }
235}
236
237async fn compose_tweet_flow(
239 state: &AppState,
240 ctx: &AccountContext,
241 body: &ComposeRequest,
242) -> Result<Json<Value>, ApiError> {
243 let content = body.content.trim().to_string();
244 if content.is_empty() {
245 return Err(ApiError::BadRequest("content is required".to_string()));
246 }
247 if tweet_weighted_len(&content) > MAX_TWEET_CHARS {
248 return Err(ApiError::BadRequest(
249 "tweet content must not exceed 280 characters".to_string(),
250 ));
251 }
252
253 persist_content(state, ctx, body, &content).await
254}
255
256async fn compose_thread_legacy_flow(
258 state: &AppState,
259 ctx: &AccountContext,
260 body: &ComposeRequest,
261) -> Result<Json<Value>, ApiError> {
262 let content = body.content.trim().to_string();
263 if content.is_empty() {
264 return Err(ApiError::BadRequest("content is required".to_string()));
265 }
266
267 let tweets: Vec<String> = serde_json::from_str(&content).map_err(|_| {
268 ApiError::BadRequest("thread content must be a JSON array of strings".to_string())
269 })?;
270
271 if tweets.is_empty() {
272 return Err(ApiError::BadRequest(
273 "thread must contain at least one tweet".to_string(),
274 ));
275 }
276
277 for (i, tweet) in tweets.iter().enumerate() {
278 if tweet_weighted_len(tweet) > MAX_TWEET_CHARS {
279 return Err(ApiError::BadRequest(format!(
280 "tweet {} exceeds 280 characters",
281 i + 1
282 )));
283 }
284 }
285
286 persist_content(state, ctx, body, &content).await
287}
288
289async fn compose_thread_blocks_flow(
291 state: &AppState,
292 ctx: &AccountContext,
293 body: &ComposeRequest,
294 block_requests: Vec<ThreadBlockRequest>,
295) -> Result<Json<Value>, ApiError> {
296 let core_blocks: Vec<ThreadBlock> = block_requests.into_iter().map(|b| b.into_core()).collect();
297
298 validate_thread_blocks(&core_blocks).map_err(|e| ApiError::BadRequest(e.api_message()))?;
299
300 let block_ids: Vec<String> = {
301 let mut sorted = core_blocks.clone();
302 sorted.sort_by_key(|b| b.order);
303 sorted.iter().map(|b| b.id.clone()).collect()
304 };
305
306 let content = serialize_blocks_for_storage(&core_blocks);
307
308 let all_media: Vec<String> = {
310 let mut sorted = core_blocks.clone();
311 sorted.sort_by_key(|b| b.order);
312 sorted.iter().flat_map(|b| b.media_paths.clone()).collect()
313 };
314
315 let normalized_schedule = match &body.scheduled_for {
317 Some(raw) => Some(
318 tuitbot_core::scheduling::validate_and_normalize(
319 raw,
320 tuitbot_core::scheduling::DEFAULT_GRACE_SECONDS,
321 )
322 .map_err(|e| ApiError::BadRequest(e.to_string()))?,
323 ),
324 None => None,
325 };
326
327 let approval_mode = read_approval_mode(state, &ctx.account_id).await?;
328
329 if approval_mode {
330 let media_json = serde_json::to_string(&all_media).unwrap_or_else(|_| "[]".to_string());
331 let prov_input = build_provenance_input(body.provenance.as_deref());
332
333 let id = approval_queue::enqueue_with_provenance_for(
334 &state.db,
335 &ctx.account_id,
336 "thread",
337 "",
338 "",
339 &content,
340 "",
341 "",
342 0.0,
343 &media_json,
344 None,
345 None,
346 prov_input.as_ref(),
347 normalized_schedule.as_deref(),
348 )
349 .await?;
350
351 let _ = state.event_tx.send(AccountWsEvent {
352 account_id: ctx.account_id.clone(),
353 event: WsEvent::ApprovalQueued {
354 id,
355 action_type: "thread".to_string(),
356 content: content.clone(),
357 media_paths: all_media,
358 },
359 });
360
361 Ok(Json(json!({
362 "status": "queued_for_approval",
363 "id": id,
364 "block_ids": block_ids,
365 "scheduled_for": normalized_schedule,
366 })))
367 } else if let Some(ref normalized) = normalized_schedule {
368 let id = scheduled_content::insert_for(
370 &state.db,
371 &ctx.account_id,
372 "thread",
373 &content,
374 Some(normalized),
375 )
376 .await?;
377
378 let _ = state.event_tx.send(AccountWsEvent {
379 account_id: ctx.account_id.clone(),
380 event: WsEvent::ContentScheduled {
381 id,
382 content_type: "thread".to_string(),
383 scheduled_for: Some(normalized.clone()),
384 },
385 });
386
387 Ok(Json(json!({
388 "status": "scheduled",
389 "id": id,
390 "block_ids": block_ids,
391 })))
392 } else {
393 let can_post = super::can_post_for(state, &ctx.account_id).await;
395 if !can_post {
396 let scheduled_for = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
397 let id = scheduled_content::insert_for(
398 &state.db,
399 &ctx.account_id,
400 "thread",
401 &content,
402 Some(&scheduled_for),
403 )
404 .await?;
405
406 let _ = state.event_tx.send(AccountWsEvent {
407 account_id: ctx.account_id.clone(),
408 event: WsEvent::ContentScheduled {
409 id,
410 content_type: "thread".to_string(),
411 scheduled_for: Some(scheduled_for),
412 },
413 });
414
415 return Ok(Json(json!({
416 "status": "scheduled",
417 "id": id,
418 "block_ids": block_ids,
419 })));
420 }
421
422 try_post_thread_now(state, ctx, &core_blocks).await
423 }
424}
425
426async fn persist_content(
428 state: &AppState,
429 ctx: &AccountContext,
430 body: &ComposeRequest,
431 content: &str,
432) -> Result<Json<Value>, ApiError> {
433 let normalized_schedule = match &body.scheduled_for {
435 Some(raw) => Some(
436 tuitbot_core::scheduling::validate_and_normalize(
437 raw,
438 tuitbot_core::scheduling::DEFAULT_GRACE_SECONDS,
439 )
440 .map_err(|e| ApiError::BadRequest(e.to_string()))?,
441 ),
442 None => None,
443 };
444
445 let approval_mode = read_approval_mode(state, &ctx.account_id).await?;
446
447 if approval_mode {
448 let media_paths = body.media_paths.as_deref().unwrap_or(&[]);
449 let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
450
451 let prov_input = build_provenance_input(body.provenance.as_deref());
452
453 let id = approval_queue::enqueue_with_provenance_for(
454 &state.db,
455 &ctx.account_id,
456 &body.content_type,
457 "",
458 "",
459 content,
460 "",
461 "",
462 0.0,
463 &media_json,
464 None,
465 None,
466 prov_input.as_ref(),
467 normalized_schedule.as_deref(),
468 )
469 .await?;
470
471 let _ = state.event_tx.send(AccountWsEvent {
472 account_id: ctx.account_id.clone(),
473 event: WsEvent::ApprovalQueued {
474 id,
475 action_type: body.content_type.clone(),
476 content: content.to_string(),
477 media_paths: media_paths.to_vec(),
478 },
479 });
480
481 Ok(Json(json!({
482 "status": "queued_for_approval",
483 "id": id,
484 "scheduled_for": normalized_schedule,
485 })))
486 } else if let Some(ref normalized) = normalized_schedule {
487 let id = scheduled_content::insert_for(
489 &state.db,
490 &ctx.account_id,
491 &body.content_type,
492 content,
493 Some(normalized),
494 )
495 .await?;
496
497 let _ = state.event_tx.send(AccountWsEvent {
498 account_id: ctx.account_id.clone(),
499 event: WsEvent::ContentScheduled {
500 id,
501 content_type: body.content_type.clone(),
502 scheduled_for: Some(normalized.clone()),
503 },
504 });
505
506 Ok(Json(json!({
507 "status": "scheduled",
508 "id": id,
509 })))
510 } else {
511 let can_post = super::can_post_for(state, &ctx.account_id).await;
514 if !can_post {
515 let scheduled_for = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
516 let id = scheduled_content::insert_for(
517 &state.db,
518 &ctx.account_id,
519 &body.content_type,
520 content,
521 Some(&scheduled_for),
522 )
523 .await?;
524
525 let _ = state.event_tx.send(AccountWsEvent {
526 account_id: ctx.account_id.clone(),
527 event: WsEvent::ContentScheduled {
528 id,
529 content_type: body.content_type.clone(),
530 scheduled_for: Some(scheduled_for),
531 },
532 });
533
534 return Ok(Json(json!({
535 "status": "scheduled",
536 "id": id,
537 })));
538 }
539
540 try_post_now(state, ctx, &body.content_type, content).await
541 }
542}
543
544async fn build_x_client(
549 state: &AppState,
550 ctx: &AccountContext,
551) -> Result<Box<dyn XApiClient>, ApiError> {
552 let config = super::read_effective_config(state, &ctx.account_id).await?;
553
554 match config.x_api.provider_backend.as_str() {
555 "scraper" => {
556 let account_data =
557 tuitbot_core::storage::accounts::account_data_dir(&state.data_dir, &ctx.account_id);
558 let client = if let Some(ref health) = state.scraper_health {
561 tuitbot_core::x_api::LocalModeXClient::with_session_and_health(
562 config.x_api.scraper_allow_mutations,
563 &account_data,
564 health.clone(),
565 )
566 .await
567 } else {
568 tuitbot_core::x_api::LocalModeXClient::with_session(
569 config.x_api.scraper_allow_mutations,
570 &account_data,
571 )
572 .await
573 };
574 Ok(Box::new(client))
575 }
576 "x_api" => {
577 let token_path = tuitbot_core::storage::accounts::account_token_path(
578 &state.data_dir,
579 &ctx.account_id,
580 );
581 let access_token = state
582 .get_x_access_token(&token_path, &ctx.account_id)
583 .await
584 .map_err(|e| {
585 ApiError::BadRequest(format!(
586 "X API authentication failed — re-link your account in Settings. ({e})"
587 ))
588 })?;
589 Ok(Box::new(XApiHttpClient::new(access_token)))
590 }
591 _ => Err(ApiError::BadRequest(
592 "Direct posting requires X API credentials or a browser session. \
593 Configure in Settings → X API."
594 .to_string(),
595 )),
596 }
597}
598
599async fn try_post_now(
601 state: &AppState,
602 ctx: &AccountContext,
603 content_type: &str,
604 content: &str,
605) -> Result<Json<Value>, ApiError> {
606 let client = build_x_client(state, ctx).await?;
607
608 let posted = client
609 .post_tweet(content)
610 .await
611 .map_err(|e| ApiError::Internal(format!("Failed to post tweet: {e}")))?;
612
613 let metadata = json!({
614 "tweet_id": posted.id,
615 "content_type": content_type,
616 "source": "compose",
617 });
618 let _ = action_log::log_action_for(
619 &state.db,
620 &ctx.account_id,
621 "tweet_posted",
622 "success",
623 Some(&format!("Posted tweet {}", posted.id)),
624 Some(&metadata.to_string()),
625 )
626 .await;
627
628 Ok(Json(json!({
629 "status": "posted",
630 "tweet_id": posted.id,
631 })))
632}
633
634async fn try_post_thread_now(
637 state: &AppState,
638 ctx: &AccountContext,
639 blocks: &[ThreadBlock],
640) -> Result<Json<Value>, ApiError> {
641 let client = build_x_client(state, ctx).await?;
642
643 let mut sorted: Vec<&ThreadBlock> = blocks.iter().collect();
644 sorted.sort_by_key(|b| b.order);
645
646 let mut tweet_ids: Vec<String> = Vec::with_capacity(sorted.len());
647
648 for (i, block) in sorted.iter().enumerate() {
649 let posted = if i == 0 {
650 client.post_tweet(&block.text).await
651 } else {
652 client.reply_to_tweet(&block.text, &tweet_ids[i - 1]).await
653 };
654
655 match posted {
656 Ok(p) => tweet_ids.push(p.id),
657 Err(e) => {
658 let metadata = json!({
660 "posted_tweet_ids": tweet_ids,
661 "failed_at_index": i,
662 "error": e.to_string(),
663 "source": "compose",
664 });
665 let _ = action_log::log_action_for(
666 &state.db,
667 &ctx.account_id,
668 "thread_posted",
669 "partial_failure",
670 Some(&format!(
671 "Thread failed at tweet {}/{}: {e}",
672 i + 1,
673 sorted.len()
674 )),
675 Some(&metadata.to_string()),
676 )
677 .await;
678
679 return Err(ApiError::Internal(format!(
680 "Thread failed at tweet {}/{}: {e}. \
681 {} tweet(s) were posted and cannot be undone.",
682 i + 1,
683 sorted.len(),
684 tweet_ids.len()
685 )));
686 }
687 }
688 }
689
690 let metadata = json!({
691 "tweet_ids": tweet_ids,
692 "content_type": "thread",
693 "source": "compose",
694 });
695 let _ = action_log::log_action_for(
696 &state.db,
697 &ctx.account_id,
698 "thread_posted",
699 "success",
700 Some(&format!("Posted thread ({} tweets)", tweet_ids.len())),
701 Some(&metadata.to_string()),
702 )
703 .await;
704
705 Ok(Json(json!({
706 "status": "posted",
707 "tweet_ids": tweet_ids,
708 })))
709}
710
711fn build_provenance_input(
713 provenance: Option<&[ProvenanceRef]>,
714) -> Option<approval_queue::ProvenanceInput> {
715 let refs = provenance?;
716 if refs.is_empty() {
717 return None;
718 }
719
720 let source_node_id = refs.iter().find_map(|r| r.node_id);
721 let source_seed_id = refs.iter().find_map(|r| r.seed_id);
722 let source_chunks_json = serde_json::to_string(refs).unwrap_or_else(|_| "[]".to_string());
723
724 Some(approval_queue::ProvenanceInput {
725 source_node_id,
726 source_seed_id,
727 source_chunks_json,
728 refs: refs.to_vec(),
729 })
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use tuitbot_core::content::ThreadBlock;
736 use tuitbot_core::storage::provenance::ProvenanceRef;
737
738 #[test]
741 fn thread_block_request_into_core_basic() {
742 let req = ThreadBlockRequest {
743 id: "uuid-1".to_string(),
744 text: "Hello world".to_string(),
745 media_paths: vec![],
746 order: 0,
747 };
748 let core = req.into_core();
749 assert_eq!(core.id, "uuid-1");
750 assert_eq!(core.text, "Hello world");
751 assert_eq!(core.order, 0);
752 assert!(core.media_paths.is_empty());
753 }
754
755 #[test]
756 fn thread_block_request_into_core_with_media() {
757 let req = ThreadBlockRequest {
758 id: "uuid-2".to_string(),
759 text: "Tweet with media".to_string(),
760 media_paths: vec!["/path/a.jpg".to_string(), "/path/b.png".to_string()],
761 order: 3,
762 };
763 let core = req.into_core();
764 assert_eq!(core.media_paths.len(), 2);
765 assert_eq!(core.media_paths[0], "/path/a.jpg");
766 assert_eq!(core.order, 3);
767 }
768
769 #[test]
770 fn thread_block_request_deserialize_without_media() {
771 let json = r#"{"id":"x","text":"hi","order":0}"#;
772 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
773 assert_eq!(req.id, "x");
774 assert!(req.media_paths.is_empty());
775 }
776
777 #[test]
778 fn thread_block_request_deserialize_with_media() {
779 let json = r#"{"id":"x","text":"hi","media_paths":["a.jpg"],"order":1}"#;
780 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
781 assert_eq!(req.media_paths.len(), 1);
782 assert_eq!(req.order, 1);
783 }
784
785 #[test]
788 fn build_provenance_input_none_returns_none() {
789 assert!(build_provenance_input(None).is_none());
790 }
791
792 #[test]
793 fn build_provenance_input_empty_slice_returns_none() {
794 let refs: Vec<ProvenanceRef> = vec![];
795 assert!(build_provenance_input(Some(&refs)).is_none());
796 }
797
798 #[test]
799 fn build_provenance_input_with_node_id() {
800 let refs = vec![ProvenanceRef {
801 node_id: Some(42),
802 chunk_id: None,
803 seed_id: None,
804 source_path: None,
805 heading_path: None,
806 snippet: None,
807 }];
808 let result = build_provenance_input(Some(&refs)).unwrap();
809 assert_eq!(result.source_node_id, Some(42));
810 assert!(result.source_seed_id.is_none());
811 assert_eq!(result.refs.len(), 1);
812 }
813
814 #[test]
815 fn build_provenance_input_with_seed_id() {
816 let refs = vec![ProvenanceRef {
817 node_id: None,
818 chunk_id: None,
819 seed_id: Some(99),
820 source_path: None,
821 heading_path: None,
822 snippet: None,
823 }];
824 let result = build_provenance_input(Some(&refs)).unwrap();
825 assert!(result.source_node_id.is_none());
826 assert_eq!(result.source_seed_id, Some(99));
827 }
828
829 #[test]
830 fn build_provenance_input_with_multiple_refs_picks_first() {
831 let refs = vec![
832 ProvenanceRef {
833 node_id: Some(1),
834 chunk_id: None,
835 seed_id: None,
836 source_path: Some("/notes/a.md".to_string()),
837 heading_path: Some("## Intro".to_string()),
838 snippet: Some("text snippet".to_string()),
839 },
840 ProvenanceRef {
841 node_id: Some(2),
842 chunk_id: Some(10),
843 seed_id: Some(50),
844 source_path: None,
845 heading_path: None,
846 snippet: None,
847 },
848 ];
849 let result = build_provenance_input(Some(&refs)).unwrap();
850 assert_eq!(result.source_node_id, Some(1));
852 assert_eq!(result.source_seed_id, Some(50));
853 assert_eq!(result.refs.len(), 2);
854 let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
856 assert_eq!(parsed.len(), 2);
857 }
858
859 #[test]
862 fn compose_tweet_request_minimal() {
863 let json = r#"{"text": "Hello"}"#;
864 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
865 assert_eq!(req.text, "Hello");
866 assert!(req.scheduled_for.is_none());
867 assert!(req.provenance.is_none());
868 }
869
870 #[test]
871 fn compose_tweet_request_with_schedule() {
872 let json = r#"{"text": "Hello", "scheduled_for": "2026-06-01T12:00:00Z"}"#;
873 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
874 assert_eq!(req.scheduled_for.as_deref(), Some("2026-06-01T12:00:00Z"));
875 }
876
877 #[test]
878 fn compose_tweet_request_with_provenance() {
879 let json = r#"{"text": "Hello", "provenance": [{"node_id": 1}]}"#;
880 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
881 let prov = req.provenance.unwrap();
882 assert_eq!(prov.len(), 1);
883 assert_eq!(prov[0].node_id, Some(1));
884 }
885
886 #[test]
889 fn compose_thread_request_basic() {
890 let json = r#"{"tweets": ["First", "Second"]}"#;
891 let req: ComposeThreadRequest = serde_json::from_str(json).unwrap();
892 assert_eq!(req.tweets.len(), 2);
893 assert!(req.scheduled_for.is_none());
894 }
895
896 #[test]
899 fn compose_request_tweet_type() {
900 let json = r#"{"content_type": "tweet", "content": "Hello world"}"#;
901 let req: ComposeRequest = serde_json::from_str(json).unwrap();
902 assert_eq!(req.content_type, "tweet");
903 assert_eq!(req.content, "Hello world");
904 assert!(req.blocks.is_none());
905 assert!(req.media_paths.is_none());
906 assert!(req.provenance.is_none());
907 }
908
909 #[test]
910 fn compose_request_thread_with_blocks() {
911 let json = r#"{
912 "content_type": "thread",
913 "content": "",
914 "blocks": [
915 {"id": "a", "text": "First", "order": 0},
916 {"id": "b", "text": "Second", "order": 1}
917 ]
918 }"#;
919 let req: ComposeRequest = serde_json::from_str(json).unwrap();
920 assert_eq!(req.content_type, "thread");
921 let blocks = req.blocks.unwrap();
922 assert_eq!(blocks.len(), 2);
923 assert_eq!(blocks[0].id, "a");
924 }
925
926 #[test]
927 fn compose_request_with_media_paths() {
928 let json = r#"{
929 "content_type": "tweet",
930 "content": "photo tweet",
931 "media_paths": ["/tmp/img.jpg"]
932 }"#;
933 let req: ComposeRequest = serde_json::from_str(json).unwrap();
934 let media = req.media_paths.unwrap();
935 assert_eq!(media.len(), 1);
936 }
937
938 #[test]
941 fn content_type_routing_tweet() {
942 let ct = "tweet";
943 assert_eq!(ct, "tweet");
944 assert_ne!(ct, "thread");
945 }
946
947 #[test]
948 fn content_type_routing_thread() {
949 let ct = "thread";
950 assert_ne!(ct, "tweet");
951 assert_eq!(ct, "thread");
952 }
953
954 #[test]
955 fn content_type_routing_unknown() {
956 let ct = "story";
957 assert_ne!(ct, "tweet");
958 assert_ne!(ct, "thread");
959 }
960
961 #[test]
964 fn tweet_validation_empty_rejected() {
965 let text = " ";
966 assert!(text.trim().is_empty());
967 }
968
969 #[test]
970 fn tweet_validation_within_limit() {
971 let text = "a".repeat(280);
972 assert!(
973 tuitbot_core::content::tweet_weighted_len(&text)
974 <= tuitbot_core::content::MAX_TWEET_CHARS
975 );
976 }
977
978 #[test]
979 fn tweet_validation_over_limit() {
980 let text = "a".repeat(281);
981 assert!(
982 tuitbot_core::content::tweet_weighted_len(&text)
983 > tuitbot_core::content::MAX_TWEET_CHARS
984 );
985 }
986
987 #[test]
990 fn legacy_thread_valid_json_array() {
991 let content = r#"["First tweet", "Second tweet"]"#;
992 let tweets: Result<Vec<String>, _> = serde_json::from_str(content);
993 assert!(tweets.is_ok());
994 assert_eq!(tweets.unwrap().len(), 2);
995 }
996
997 #[test]
998 fn legacy_thread_invalid_json() {
999 let content = "not json at all";
1000 let tweets: Result<Vec<String>, _> = serde_json::from_str(content);
1001 assert!(tweets.is_err());
1002 }
1003
1004 #[test]
1005 fn legacy_thread_empty_array() {
1006 let content = "[]";
1007 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
1008 assert!(tweets.is_empty());
1009 }
1010
1011 #[test]
1014 fn block_requests_to_core_preserves_order() {
1015 let reqs = vec![
1016 ThreadBlockRequest {
1017 id: "c".to_string(),
1018 text: "Third".to_string(),
1019 media_paths: vec![],
1020 order: 2,
1021 },
1022 ThreadBlockRequest {
1023 id: "a".to_string(),
1024 text: "First".to_string(),
1025 media_paths: vec![],
1026 order: 0,
1027 },
1028 ThreadBlockRequest {
1029 id: "b".to_string(),
1030 text: "Second".to_string(),
1031 media_paths: vec![],
1032 order: 1,
1033 },
1034 ];
1035 let core_blocks: Vec<ThreadBlock> = reqs.into_iter().map(|b| b.into_core()).collect();
1036 assert_eq!(core_blocks.len(), 3);
1037
1038 let mut sorted = core_blocks.clone();
1040 sorted.sort_by_key(|b| b.order);
1041 let ids: Vec<String> = sorted.iter().map(|b| b.id.clone()).collect();
1042 assert_eq!(ids, vec!["a", "b", "c"]);
1043 }
1044
1045 #[test]
1048 fn media_json_empty() {
1049 let media: Vec<String> = vec![];
1050 let json = serde_json::to_string(&media).unwrap();
1051 assert_eq!(json, "[]");
1052 }
1053
1054 #[test]
1055 fn media_json_with_paths() {
1056 let media = vec!["a.jpg".to_string(), "b.png".to_string()];
1057 let json = serde_json::to_string(&media).unwrap();
1058 assert!(json.contains("a.jpg"));
1059 assert!(json.contains("b.png"));
1060 }
1061
1062 #[test]
1065 fn validate_thread_blocks_from_requests() {
1066 let reqs = vec![
1067 ThreadBlockRequest {
1068 id: "a".to_string(),
1069 text: "First tweet".to_string(),
1070 media_paths: vec![],
1071 order: 0,
1072 },
1073 ThreadBlockRequest {
1074 id: "b".to_string(),
1075 text: "Second tweet".to_string(),
1076 media_paths: vec![],
1077 order: 1,
1078 },
1079 ];
1080 let core_blocks: Vec<ThreadBlock> = reqs.into_iter().map(|b| b.into_core()).collect();
1081 assert!(tuitbot_core::content::validate_thread_blocks(&core_blocks).is_ok());
1082 }
1083
1084 #[test]
1085 fn serialize_blocks_roundtrip() {
1086 let blocks = vec![
1087 ThreadBlock {
1088 id: "a".to_string(),
1089 text: "First".to_string(),
1090 media_paths: vec!["img.jpg".to_string()],
1091 order: 0,
1092 },
1093 ThreadBlock {
1094 id: "b".to_string(),
1095 text: "Second".to_string(),
1096 media_paths: vec![],
1097 order: 1,
1098 },
1099 ];
1100 let serialized = tuitbot_core::content::serialize_blocks_for_storage(&blocks);
1101 let deserialized =
1102 tuitbot_core::content::deserialize_blocks_from_content(&serialized).unwrap();
1103 assert_eq!(deserialized.len(), 2);
1104 assert_eq!(deserialized[0].id, "a");
1105 assert_eq!(deserialized[0].media_paths.len(), 1);
1106 }
1107
1108 #[test]
1111 fn validate_empty_blocks_fails() {
1112 let blocks: Vec<ThreadBlock> = vec![];
1113 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1114 assert!(result.is_err());
1115 }
1116
1117 #[test]
1118 fn validate_single_block_fails() {
1119 let blocks = vec![ThreadBlock {
1121 id: "a".to_string(),
1122 text: "Solo tweet".to_string(),
1123 media_paths: vec![],
1124 order: 0,
1125 }];
1126 assert!(tuitbot_core::content::validate_thread_blocks(&blocks).is_err());
1127 }
1128
1129 #[test]
1130 fn validate_block_with_empty_text_fails() {
1131 let blocks = vec![ThreadBlock {
1132 id: "a".to_string(),
1133 text: " ".to_string(),
1134 media_paths: vec![],
1135 order: 0,
1136 }];
1137 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1138 assert!(result.is_err());
1139 }
1140
1141 #[test]
1142 fn validate_block_over_280_chars_fails() {
1143 let blocks = vec![ThreadBlock {
1144 id: "a".to_string(),
1145 text: "x".repeat(281),
1146 media_paths: vec![],
1147 order: 0,
1148 }];
1149 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1150 assert!(result.is_err());
1151 }
1152
1153 #[test]
1156 fn compose_tweet_request_with_empty_provenance() {
1157 let json = r#"{"text": "Hello", "provenance": []}"#;
1158 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1159 assert!(req.provenance.unwrap().is_empty());
1160 }
1161
1162 #[test]
1165 fn compose_request_with_empty_blocks() {
1166 let json = r#"{
1167 "content_type": "thread",
1168 "content": "",
1169 "blocks": []
1170 }"#;
1171 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1172 assert!(req.blocks.unwrap().is_empty());
1173 }
1174
1175 #[test]
1176 fn compose_request_with_scheduled_for() {
1177 let json = r#"{
1178 "content_type": "tweet",
1179 "content": "scheduled tweet",
1180 "scheduled_for": "2026-06-01T12:00:00Z"
1181 }"#;
1182 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1183 assert_eq!(req.scheduled_for.as_deref(), Some("2026-06-01T12:00:00Z"));
1184 }
1185
1186 #[test]
1187 fn compose_request_with_provenance() {
1188 let json = r#"{
1189 "content_type": "tweet",
1190 "content": "text",
1191 "provenance": [{"node_id": 5, "chunk_id": 10}]
1192 }"#;
1193 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1194 let prov = req.provenance.unwrap();
1195 assert_eq!(prov.len(), 1);
1196 assert_eq!(prov[0].node_id, Some(5));
1197 assert_eq!(prov[0].chunk_id, Some(10));
1198 }
1199
1200 #[test]
1203 fn build_provenance_input_all_none_fields() {
1204 let refs = vec![ProvenanceRef {
1205 node_id: None,
1206 chunk_id: None,
1207 seed_id: None,
1208 source_path: None,
1209 heading_path: None,
1210 snippet: None,
1211 }];
1212 let result = build_provenance_input(Some(&refs)).unwrap();
1213 assert!(result.source_node_id.is_none());
1214 assert!(result.source_seed_id.is_none());
1215 assert_eq!(result.refs.len(), 1);
1216 let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
1218 assert_eq!(parsed.len(), 1);
1219 }
1220
1221 #[test]
1224 fn tweet_len_exactly_280() {
1225 let text = "a".repeat(280);
1226 assert_eq!(tuitbot_core::content::tweet_weighted_len(&text), 280);
1227 }
1228
1229 #[test]
1230 fn tweet_len_with_url() {
1231 let text = "Check out https://example.com/some/long/path/here";
1233 let len = tuitbot_core::content::tweet_weighted_len(text);
1234 assert!(len < text.len(), "URL should be shortened in weighted len");
1236 }
1237
1238 #[test]
1241 fn block_media_aggregation() {
1242 let blocks = vec![
1243 ThreadBlock {
1244 id: "a".to_string(),
1245 text: "First".to_string(),
1246 media_paths: vec!["img1.jpg".to_string()],
1247 order: 0,
1248 },
1249 ThreadBlock {
1250 id: "b".to_string(),
1251 text: "Second".to_string(),
1252 media_paths: vec!["img2.png".to_string(), "img3.gif".to_string()],
1253 order: 1,
1254 },
1255 ThreadBlock {
1256 id: "c".to_string(),
1257 text: "Third".to_string(),
1258 media_paths: vec![],
1259 order: 2,
1260 },
1261 ];
1262 let mut sorted = blocks.clone();
1263 sorted.sort_by_key(|b| b.order);
1264 let all_media: Vec<String> = sorted.iter().flat_map(|b| b.media_paths.clone()).collect();
1265 assert_eq!(all_media.len(), 3);
1266 assert_eq!(all_media[0], "img1.jpg");
1267 assert_eq!(all_media[1], "img2.png");
1268 assert_eq!(all_media[2], "img3.gif");
1269 }
1270
1271 #[test]
1274 fn legacy_thread_single_tweet() {
1275 let content = r#"["Only tweet"]"#;
1276 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
1277 assert_eq!(tweets.len(), 1);
1278 assert_eq!(tweets[0], "Only tweet");
1279 }
1280
1281 #[test]
1282 fn legacy_thread_with_special_chars() {
1283 let content = r#"["Hello \"world\"", "Tweet with\nnewline"]"#;
1284 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
1285 assert_eq!(tweets.len(), 2);
1286 assert!(tweets[0].contains('"'));
1287 }
1288
1289 #[test]
1290 fn legacy_thread_combined_separator() {
1291 let tweets = vec!["First".to_string(), "Second".to_string()];
1292 let combined = tweets.join("\n---\n");
1293 assert_eq!(combined, "First\n---\nSecond");
1294 assert!(combined.contains("---"));
1295 }
1296
1297 #[test]
1298 fn thread_block_request_into_core() {
1299 let req = ThreadBlockRequest {
1300 id: "uuid-1".to_string(),
1301 text: "Hello".to_string(),
1302 media_paths: vec!["img.png".to_string()],
1303 order: 0,
1304 };
1305 let core = req.into_core();
1306 assert_eq!(core.id, "uuid-1");
1307 assert_eq!(core.text, "Hello");
1308 assert_eq!(core.media_paths.len(), 1);
1309 assert_eq!(core.order, 0);
1310 }
1311
1312 #[test]
1313 fn thread_block_request_default_media_paths() {
1314 let json = r#"{"id":"u1","text":"t","order":0}"#;
1315 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
1316 assert!(req.media_paths.is_empty());
1317 }
1318
1319 #[test]
1320 fn compose_tweet_request_text_only() {
1321 let json = r#"{"text":"Hello world"}"#;
1322 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1323 assert_eq!(req.text, "Hello world");
1324 assert!(req.scheduled_for.is_none());
1325 assert!(req.provenance.is_none());
1326 }
1327
1328 #[test]
1329 fn compose_tweet_request_scheduled() {
1330 let json = r#"{"text":"Later","scheduled_for":"2026-04-01T10:00:00Z"}"#;
1331 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1332 assert_eq!(req.scheduled_for.as_deref(), Some("2026-04-01T10:00:00Z"));
1333 }
1334}