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 = tuitbot_core::x_api::LocalModeXClient::with_session(
559 config.x_api.scraper_allow_mutations,
560 &account_data,
561 )
562 .await;
563 Ok(Box::new(client))
564 }
565 "x_api" => {
566 let token_path = tuitbot_core::storage::accounts::account_token_path(
567 &state.data_dir,
568 &ctx.account_id,
569 );
570 let access_token = state
571 .get_x_access_token(&token_path, &ctx.account_id)
572 .await
573 .map_err(|e| {
574 ApiError::BadRequest(format!(
575 "X API authentication failed — re-link your account in Settings. ({e})"
576 ))
577 })?;
578 Ok(Box::new(XApiHttpClient::new(access_token)))
579 }
580 _ => Err(ApiError::BadRequest(
581 "Direct posting requires X API credentials or a browser session. \
582 Configure in Settings → X API."
583 .to_string(),
584 )),
585 }
586}
587
588async fn try_post_now(
590 state: &AppState,
591 ctx: &AccountContext,
592 content_type: &str,
593 content: &str,
594) -> Result<Json<Value>, ApiError> {
595 let client = build_x_client(state, ctx).await?;
596
597 let posted = client
598 .post_tweet(content)
599 .await
600 .map_err(|e| ApiError::Internal(format!("Failed to post tweet: {e}")))?;
601
602 let metadata = json!({
603 "tweet_id": posted.id,
604 "content_type": content_type,
605 "source": "compose",
606 });
607 let _ = action_log::log_action_for(
608 &state.db,
609 &ctx.account_id,
610 "tweet_posted",
611 "success",
612 Some(&format!("Posted tweet {}", posted.id)),
613 Some(&metadata.to_string()),
614 )
615 .await;
616
617 Ok(Json(json!({
618 "status": "posted",
619 "tweet_id": posted.id,
620 })))
621}
622
623async fn try_post_thread_now(
626 state: &AppState,
627 ctx: &AccountContext,
628 blocks: &[ThreadBlock],
629) -> Result<Json<Value>, ApiError> {
630 let client = build_x_client(state, ctx).await?;
631
632 let mut sorted: Vec<&ThreadBlock> = blocks.iter().collect();
633 sorted.sort_by_key(|b| b.order);
634
635 let mut tweet_ids: Vec<String> = Vec::with_capacity(sorted.len());
636
637 for (i, block) in sorted.iter().enumerate() {
638 let posted = if i == 0 {
639 client.post_tweet(&block.text).await
640 } else {
641 client.reply_to_tweet(&block.text, &tweet_ids[i - 1]).await
642 };
643
644 match posted {
645 Ok(p) => tweet_ids.push(p.id),
646 Err(e) => {
647 let metadata = json!({
649 "posted_tweet_ids": tweet_ids,
650 "failed_at_index": i,
651 "error": e.to_string(),
652 "source": "compose",
653 });
654 let _ = action_log::log_action_for(
655 &state.db,
656 &ctx.account_id,
657 "thread_posted",
658 "partial_failure",
659 Some(&format!(
660 "Thread failed at tweet {}/{}: {e}",
661 i + 1,
662 sorted.len()
663 )),
664 Some(&metadata.to_string()),
665 )
666 .await;
667
668 return Err(ApiError::Internal(format!(
669 "Thread failed at tweet {}/{}: {e}. \
670 {} tweet(s) were posted and cannot be undone.",
671 i + 1,
672 sorted.len(),
673 tweet_ids.len()
674 )));
675 }
676 }
677 }
678
679 let metadata = json!({
680 "tweet_ids": tweet_ids,
681 "content_type": "thread",
682 "source": "compose",
683 });
684 let _ = action_log::log_action_for(
685 &state.db,
686 &ctx.account_id,
687 "thread_posted",
688 "success",
689 Some(&format!("Posted thread ({} tweets)", tweet_ids.len())),
690 Some(&metadata.to_string()),
691 )
692 .await;
693
694 Ok(Json(json!({
695 "status": "posted",
696 "tweet_ids": tweet_ids,
697 })))
698}
699
700fn build_provenance_input(
702 provenance: Option<&[ProvenanceRef]>,
703) -> Option<approval_queue::ProvenanceInput> {
704 let refs = provenance?;
705 if refs.is_empty() {
706 return None;
707 }
708
709 let source_node_id = refs.iter().find_map(|r| r.node_id);
710 let source_seed_id = refs.iter().find_map(|r| r.seed_id);
711 let source_chunks_json = serde_json::to_string(refs).unwrap_or_else(|_| "[]".to_string());
712
713 Some(approval_queue::ProvenanceInput {
714 source_node_id,
715 source_seed_id,
716 source_chunks_json,
717 refs: refs.to_vec(),
718 })
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use tuitbot_core::content::ThreadBlock;
725 use tuitbot_core::storage::provenance::ProvenanceRef;
726
727 #[test]
730 fn thread_block_request_into_core_basic() {
731 let req = ThreadBlockRequest {
732 id: "uuid-1".to_string(),
733 text: "Hello world".to_string(),
734 media_paths: vec![],
735 order: 0,
736 };
737 let core = req.into_core();
738 assert_eq!(core.id, "uuid-1");
739 assert_eq!(core.text, "Hello world");
740 assert_eq!(core.order, 0);
741 assert!(core.media_paths.is_empty());
742 }
743
744 #[test]
745 fn thread_block_request_into_core_with_media() {
746 let req = ThreadBlockRequest {
747 id: "uuid-2".to_string(),
748 text: "Tweet with media".to_string(),
749 media_paths: vec!["/path/a.jpg".to_string(), "/path/b.png".to_string()],
750 order: 3,
751 };
752 let core = req.into_core();
753 assert_eq!(core.media_paths.len(), 2);
754 assert_eq!(core.media_paths[0], "/path/a.jpg");
755 assert_eq!(core.order, 3);
756 }
757
758 #[test]
759 fn thread_block_request_deserialize_without_media() {
760 let json = r#"{"id":"x","text":"hi","order":0}"#;
761 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
762 assert_eq!(req.id, "x");
763 assert!(req.media_paths.is_empty());
764 }
765
766 #[test]
767 fn thread_block_request_deserialize_with_media() {
768 let json = r#"{"id":"x","text":"hi","media_paths":["a.jpg"],"order":1}"#;
769 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
770 assert_eq!(req.media_paths.len(), 1);
771 assert_eq!(req.order, 1);
772 }
773
774 #[test]
777 fn build_provenance_input_none_returns_none() {
778 assert!(build_provenance_input(None).is_none());
779 }
780
781 #[test]
782 fn build_provenance_input_empty_slice_returns_none() {
783 let refs: Vec<ProvenanceRef> = vec![];
784 assert!(build_provenance_input(Some(&refs)).is_none());
785 }
786
787 #[test]
788 fn build_provenance_input_with_node_id() {
789 let refs = vec![ProvenanceRef {
790 node_id: Some(42),
791 chunk_id: None,
792 seed_id: None,
793 source_path: None,
794 heading_path: None,
795 snippet: None,
796 }];
797 let result = build_provenance_input(Some(&refs)).unwrap();
798 assert_eq!(result.source_node_id, Some(42));
799 assert!(result.source_seed_id.is_none());
800 assert_eq!(result.refs.len(), 1);
801 }
802
803 #[test]
804 fn build_provenance_input_with_seed_id() {
805 let refs = vec![ProvenanceRef {
806 node_id: None,
807 chunk_id: None,
808 seed_id: Some(99),
809 source_path: None,
810 heading_path: None,
811 snippet: None,
812 }];
813 let result = build_provenance_input(Some(&refs)).unwrap();
814 assert!(result.source_node_id.is_none());
815 assert_eq!(result.source_seed_id, Some(99));
816 }
817
818 #[test]
819 fn build_provenance_input_with_multiple_refs_picks_first() {
820 let refs = vec![
821 ProvenanceRef {
822 node_id: Some(1),
823 chunk_id: None,
824 seed_id: None,
825 source_path: Some("/notes/a.md".to_string()),
826 heading_path: Some("## Intro".to_string()),
827 snippet: Some("text snippet".to_string()),
828 },
829 ProvenanceRef {
830 node_id: Some(2),
831 chunk_id: Some(10),
832 seed_id: Some(50),
833 source_path: None,
834 heading_path: None,
835 snippet: None,
836 },
837 ];
838 let result = build_provenance_input(Some(&refs)).unwrap();
839 assert_eq!(result.source_node_id, Some(1));
841 assert_eq!(result.source_seed_id, Some(50));
842 assert_eq!(result.refs.len(), 2);
843 let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
845 assert_eq!(parsed.len(), 2);
846 }
847
848 #[test]
851 fn compose_tweet_request_minimal() {
852 let json = r#"{"text": "Hello"}"#;
853 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
854 assert_eq!(req.text, "Hello");
855 assert!(req.scheduled_for.is_none());
856 assert!(req.provenance.is_none());
857 }
858
859 #[test]
860 fn compose_tweet_request_with_schedule() {
861 let json = r#"{"text": "Hello", "scheduled_for": "2026-06-01T12:00:00Z"}"#;
862 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
863 assert_eq!(req.scheduled_for.as_deref(), Some("2026-06-01T12:00:00Z"));
864 }
865
866 #[test]
867 fn compose_tweet_request_with_provenance() {
868 let json = r#"{"text": "Hello", "provenance": [{"node_id": 1}]}"#;
869 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
870 let prov = req.provenance.unwrap();
871 assert_eq!(prov.len(), 1);
872 assert_eq!(prov[0].node_id, Some(1));
873 }
874
875 #[test]
878 fn compose_thread_request_basic() {
879 let json = r#"{"tweets": ["First", "Second"]}"#;
880 let req: ComposeThreadRequest = serde_json::from_str(json).unwrap();
881 assert_eq!(req.tweets.len(), 2);
882 assert!(req.scheduled_for.is_none());
883 }
884
885 #[test]
888 fn compose_request_tweet_type() {
889 let json = r#"{"content_type": "tweet", "content": "Hello world"}"#;
890 let req: ComposeRequest = serde_json::from_str(json).unwrap();
891 assert_eq!(req.content_type, "tweet");
892 assert_eq!(req.content, "Hello world");
893 assert!(req.blocks.is_none());
894 assert!(req.media_paths.is_none());
895 assert!(req.provenance.is_none());
896 }
897
898 #[test]
899 fn compose_request_thread_with_blocks() {
900 let json = r#"{
901 "content_type": "thread",
902 "content": "",
903 "blocks": [
904 {"id": "a", "text": "First", "order": 0},
905 {"id": "b", "text": "Second", "order": 1}
906 ]
907 }"#;
908 let req: ComposeRequest = serde_json::from_str(json).unwrap();
909 assert_eq!(req.content_type, "thread");
910 let blocks = req.blocks.unwrap();
911 assert_eq!(blocks.len(), 2);
912 assert_eq!(blocks[0].id, "a");
913 }
914
915 #[test]
916 fn compose_request_with_media_paths() {
917 let json = r#"{
918 "content_type": "tweet",
919 "content": "photo tweet",
920 "media_paths": ["/tmp/img.jpg"]
921 }"#;
922 let req: ComposeRequest = serde_json::from_str(json).unwrap();
923 let media = req.media_paths.unwrap();
924 assert_eq!(media.len(), 1);
925 }
926
927 #[test]
930 fn content_type_routing_tweet() {
931 let ct = "tweet";
932 assert_eq!(ct, "tweet");
933 assert_ne!(ct, "thread");
934 }
935
936 #[test]
937 fn content_type_routing_thread() {
938 let ct = "thread";
939 assert_ne!(ct, "tweet");
940 assert_eq!(ct, "thread");
941 }
942
943 #[test]
944 fn content_type_routing_unknown() {
945 let ct = "story";
946 assert_ne!(ct, "tweet");
947 assert_ne!(ct, "thread");
948 }
949
950 #[test]
953 fn tweet_validation_empty_rejected() {
954 let text = " ";
955 assert!(text.trim().is_empty());
956 }
957
958 #[test]
959 fn tweet_validation_within_limit() {
960 let text = "a".repeat(280);
961 assert!(
962 tuitbot_core::content::tweet_weighted_len(&text)
963 <= tuitbot_core::content::MAX_TWEET_CHARS
964 );
965 }
966
967 #[test]
968 fn tweet_validation_over_limit() {
969 let text = "a".repeat(281);
970 assert!(
971 tuitbot_core::content::tweet_weighted_len(&text)
972 > tuitbot_core::content::MAX_TWEET_CHARS
973 );
974 }
975
976 #[test]
979 fn legacy_thread_valid_json_array() {
980 let content = r#"["First tweet", "Second tweet"]"#;
981 let tweets: Result<Vec<String>, _> = serde_json::from_str(content);
982 assert!(tweets.is_ok());
983 assert_eq!(tweets.unwrap().len(), 2);
984 }
985
986 #[test]
987 fn legacy_thread_invalid_json() {
988 let content = "not json at all";
989 let tweets: Result<Vec<String>, _> = serde_json::from_str(content);
990 assert!(tweets.is_err());
991 }
992
993 #[test]
994 fn legacy_thread_empty_array() {
995 let content = "[]";
996 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
997 assert!(tweets.is_empty());
998 }
999
1000 #[test]
1003 fn block_requests_to_core_preserves_order() {
1004 let reqs = vec![
1005 ThreadBlockRequest {
1006 id: "c".to_string(),
1007 text: "Third".to_string(),
1008 media_paths: vec![],
1009 order: 2,
1010 },
1011 ThreadBlockRequest {
1012 id: "a".to_string(),
1013 text: "First".to_string(),
1014 media_paths: vec![],
1015 order: 0,
1016 },
1017 ThreadBlockRequest {
1018 id: "b".to_string(),
1019 text: "Second".to_string(),
1020 media_paths: vec![],
1021 order: 1,
1022 },
1023 ];
1024 let core_blocks: Vec<ThreadBlock> = reqs.into_iter().map(|b| b.into_core()).collect();
1025 assert_eq!(core_blocks.len(), 3);
1026
1027 let mut sorted = core_blocks.clone();
1029 sorted.sort_by_key(|b| b.order);
1030 let ids: Vec<String> = sorted.iter().map(|b| b.id.clone()).collect();
1031 assert_eq!(ids, vec!["a", "b", "c"]);
1032 }
1033
1034 #[test]
1037 fn media_json_empty() {
1038 let media: Vec<String> = vec![];
1039 let json = serde_json::to_string(&media).unwrap();
1040 assert_eq!(json, "[]");
1041 }
1042
1043 #[test]
1044 fn media_json_with_paths() {
1045 let media = vec!["a.jpg".to_string(), "b.png".to_string()];
1046 let json = serde_json::to_string(&media).unwrap();
1047 assert!(json.contains("a.jpg"));
1048 assert!(json.contains("b.png"));
1049 }
1050
1051 #[test]
1054 fn validate_thread_blocks_from_requests() {
1055 let reqs = vec![
1056 ThreadBlockRequest {
1057 id: "a".to_string(),
1058 text: "First tweet".to_string(),
1059 media_paths: vec![],
1060 order: 0,
1061 },
1062 ThreadBlockRequest {
1063 id: "b".to_string(),
1064 text: "Second tweet".to_string(),
1065 media_paths: vec![],
1066 order: 1,
1067 },
1068 ];
1069 let core_blocks: Vec<ThreadBlock> = reqs.into_iter().map(|b| b.into_core()).collect();
1070 assert!(tuitbot_core::content::validate_thread_blocks(&core_blocks).is_ok());
1071 }
1072
1073 #[test]
1074 fn serialize_blocks_roundtrip() {
1075 let blocks = vec![
1076 ThreadBlock {
1077 id: "a".to_string(),
1078 text: "First".to_string(),
1079 media_paths: vec!["img.jpg".to_string()],
1080 order: 0,
1081 },
1082 ThreadBlock {
1083 id: "b".to_string(),
1084 text: "Second".to_string(),
1085 media_paths: vec![],
1086 order: 1,
1087 },
1088 ];
1089 let serialized = tuitbot_core::content::serialize_blocks_for_storage(&blocks);
1090 let deserialized =
1091 tuitbot_core::content::deserialize_blocks_from_content(&serialized).unwrap();
1092 assert_eq!(deserialized.len(), 2);
1093 assert_eq!(deserialized[0].id, "a");
1094 assert_eq!(deserialized[0].media_paths.len(), 1);
1095 }
1096
1097 #[test]
1100 fn validate_empty_blocks_fails() {
1101 let blocks: Vec<ThreadBlock> = vec![];
1102 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1103 assert!(result.is_err());
1104 }
1105
1106 #[test]
1107 fn validate_single_block_fails() {
1108 let blocks = vec![ThreadBlock {
1110 id: "a".to_string(),
1111 text: "Solo tweet".to_string(),
1112 media_paths: vec![],
1113 order: 0,
1114 }];
1115 assert!(tuitbot_core::content::validate_thread_blocks(&blocks).is_err());
1116 }
1117
1118 #[test]
1119 fn validate_block_with_empty_text_fails() {
1120 let blocks = vec![ThreadBlock {
1121 id: "a".to_string(),
1122 text: " ".to_string(),
1123 media_paths: vec![],
1124 order: 0,
1125 }];
1126 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1127 assert!(result.is_err());
1128 }
1129
1130 #[test]
1131 fn validate_block_over_280_chars_fails() {
1132 let blocks = vec![ThreadBlock {
1133 id: "a".to_string(),
1134 text: "x".repeat(281),
1135 media_paths: vec![],
1136 order: 0,
1137 }];
1138 let result = tuitbot_core::content::validate_thread_blocks(&blocks);
1139 assert!(result.is_err());
1140 }
1141
1142 #[test]
1145 fn compose_tweet_request_with_empty_provenance() {
1146 let json = r#"{"text": "Hello", "provenance": []}"#;
1147 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1148 assert!(req.provenance.unwrap().is_empty());
1149 }
1150
1151 #[test]
1154 fn compose_request_with_empty_blocks() {
1155 let json = r#"{
1156 "content_type": "thread",
1157 "content": "",
1158 "blocks": []
1159 }"#;
1160 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1161 assert!(req.blocks.unwrap().is_empty());
1162 }
1163
1164 #[test]
1165 fn compose_request_with_scheduled_for() {
1166 let json = r#"{
1167 "content_type": "tweet",
1168 "content": "scheduled tweet",
1169 "scheduled_for": "2026-06-01T12:00:00Z"
1170 }"#;
1171 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1172 assert_eq!(req.scheduled_for.as_deref(), Some("2026-06-01T12:00:00Z"));
1173 }
1174
1175 #[test]
1176 fn compose_request_with_provenance() {
1177 let json = r#"{
1178 "content_type": "tweet",
1179 "content": "text",
1180 "provenance": [{"node_id": 5, "chunk_id": 10}]
1181 }"#;
1182 let req: ComposeRequest = serde_json::from_str(json).unwrap();
1183 let prov = req.provenance.unwrap();
1184 assert_eq!(prov.len(), 1);
1185 assert_eq!(prov[0].node_id, Some(5));
1186 assert_eq!(prov[0].chunk_id, Some(10));
1187 }
1188
1189 #[test]
1192 fn build_provenance_input_all_none_fields() {
1193 let refs = vec![ProvenanceRef {
1194 node_id: None,
1195 chunk_id: None,
1196 seed_id: None,
1197 source_path: None,
1198 heading_path: None,
1199 snippet: None,
1200 }];
1201 let result = build_provenance_input(Some(&refs)).unwrap();
1202 assert!(result.source_node_id.is_none());
1203 assert!(result.source_seed_id.is_none());
1204 assert_eq!(result.refs.len(), 1);
1205 let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
1207 assert_eq!(parsed.len(), 1);
1208 }
1209
1210 #[test]
1213 fn tweet_len_exactly_280() {
1214 let text = "a".repeat(280);
1215 assert_eq!(tuitbot_core::content::tweet_weighted_len(&text), 280);
1216 }
1217
1218 #[test]
1219 fn tweet_len_with_url() {
1220 let text = "Check out https://example.com/some/long/path/here";
1222 let len = tuitbot_core::content::tweet_weighted_len(text);
1223 assert!(len < text.len(), "URL should be shortened in weighted len");
1225 }
1226
1227 #[test]
1230 fn block_media_aggregation() {
1231 let blocks = vec![
1232 ThreadBlock {
1233 id: "a".to_string(),
1234 text: "First".to_string(),
1235 media_paths: vec!["img1.jpg".to_string()],
1236 order: 0,
1237 },
1238 ThreadBlock {
1239 id: "b".to_string(),
1240 text: "Second".to_string(),
1241 media_paths: vec!["img2.png".to_string(), "img3.gif".to_string()],
1242 order: 1,
1243 },
1244 ThreadBlock {
1245 id: "c".to_string(),
1246 text: "Third".to_string(),
1247 media_paths: vec![],
1248 order: 2,
1249 },
1250 ];
1251 let mut sorted = blocks.clone();
1252 sorted.sort_by_key(|b| b.order);
1253 let all_media: Vec<String> = sorted.iter().flat_map(|b| b.media_paths.clone()).collect();
1254 assert_eq!(all_media.len(), 3);
1255 assert_eq!(all_media[0], "img1.jpg");
1256 assert_eq!(all_media[1], "img2.png");
1257 assert_eq!(all_media[2], "img3.gif");
1258 }
1259
1260 #[test]
1263 fn legacy_thread_single_tweet() {
1264 let content = r#"["Only tweet"]"#;
1265 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
1266 assert_eq!(tweets.len(), 1);
1267 assert_eq!(tweets[0], "Only tweet");
1268 }
1269
1270 #[test]
1271 fn legacy_thread_with_special_chars() {
1272 let content = r#"["Hello \"world\"", "Tweet with\nnewline"]"#;
1273 let tweets: Vec<String> = serde_json::from_str(content).unwrap();
1274 assert_eq!(tweets.len(), 2);
1275 assert!(tweets[0].contains('"'));
1276 }
1277
1278 #[test]
1279 fn legacy_thread_combined_separator() {
1280 let tweets = vec!["First".to_string(), "Second".to_string()];
1281 let combined = tweets.join("\n---\n");
1282 assert_eq!(combined, "First\n---\nSecond");
1283 assert!(combined.contains("---"));
1284 }
1285
1286 #[test]
1287 fn thread_block_request_into_core() {
1288 let req = ThreadBlockRequest {
1289 id: "uuid-1".to_string(),
1290 text: "Hello".to_string(),
1291 media_paths: vec!["img.png".to_string()],
1292 order: 0,
1293 };
1294 let core = req.into_core();
1295 assert_eq!(core.id, "uuid-1");
1296 assert_eq!(core.text, "Hello");
1297 assert_eq!(core.media_paths.len(), 1);
1298 assert_eq!(core.order, 0);
1299 }
1300
1301 #[test]
1302 fn thread_block_request_default_media_paths() {
1303 let json = r#"{"id":"u1","text":"t","order":0}"#;
1304 let req: ThreadBlockRequest = serde_json::from_str(json).unwrap();
1305 assert!(req.media_paths.is_empty());
1306 }
1307
1308 #[test]
1309 fn compose_tweet_request_text_only() {
1310 let json = r#"{"text":"Hello world"}"#;
1311 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1312 assert_eq!(req.text, "Hello world");
1313 assert!(req.scheduled_for.is_none());
1314 assert!(req.provenance.is_none());
1315 }
1316
1317 #[test]
1318 fn compose_tweet_request_scheduled() {
1319 let json = r#"{"text":"Later","scheduled_for":"2026-04-01T10:00:00Z"}"#;
1320 let req: ComposeTweetRequest = serde_json::from_str(json).unwrap();
1321 assert_eq!(req.scheduled_for.as_deref(), Some("2026-04-01T10:00:00Z"));
1322 }
1323}