Skip to main content

tuitbot_server/routes/content/
compose.rs

1//! Compose endpoints for tweets, threads, and unified compose.
2
3use 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/// A single thread block in an API request payload.
25#[derive(Debug, Deserialize)]
26pub struct ThreadBlockRequest {
27    /// Client-generated stable UUID.
28    pub id: String,
29    /// Tweet text content.
30    pub text: String,
31    /// Per-block media file paths.
32    #[serde(default)]
33    pub media_paths: Vec<String>,
34    /// Zero-based ordering index.
35    pub order: u32,
36}
37
38impl ThreadBlockRequest {
39    /// Convert to the core domain type.
40    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/// Request body for composing a manual tweet.
51#[derive(Deserialize)]
52pub struct ComposeTweetRequest {
53    /// The tweet text.
54    pub text: String,
55    /// Optional ISO 8601 timestamp to schedule the tweet.
56    pub scheduled_for: Option<String>,
57    /// Optional provenance refs linking this content to vault source material.
58    #[serde(default)]
59    pub provenance: Option<Vec<ProvenanceRef>>,
60}
61
62/// `POST /api/content/tweets` — compose and queue a manual tweet.
63pub 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    // Check if approval mode is enabled.
76    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            "", // no target tweet
86            "", // no target author
87            text,
88            "", // no topic
89            "", // no archetype
90            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        // Without X API client in AppState, we can only acknowledge the intent.
116        Ok(Json(json!({
117            "status": "accepted",
118            "text": text,
119            "scheduled_for": body.scheduled_for,
120        })))
121    }
122}
123
124/// Request body for composing a manual thread.
125#[derive(Deserialize)]
126pub struct ComposeThreadRequest {
127    /// The tweets forming the thread.
128    pub tweets: Vec<String>,
129    /// Optional ISO 8601 timestamp to schedule the thread.
130    pub scheduled_for: Option<String>,
131}
132
133/// `POST /api/content/threads` — compose and queue a manual thread.
134pub 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/// Request body for the unified compose endpoint.
193#[derive(Deserialize)]
194pub struct ComposeRequest {
195    /// Content type: "tweet" or "thread".
196    pub content_type: String,
197    /// Content text (string for tweet, JSON array string for thread).
198    pub content: String,
199    /// Optional ISO 8601 timestamp to schedule the content.
200    pub scheduled_for: Option<String>,
201    /// Optional local media file paths to attach (top-level, used for tweets).
202    #[serde(default)]
203    pub media_paths: Option<Vec<String>>,
204    /// Optional structured thread blocks. Takes precedence over `content` for threads.
205    #[serde(default)]
206    pub blocks: Option<Vec<ThreadBlockRequest>>,
207    /// Optional provenance refs linking this content to vault source material.
208    #[serde(default)]
209    pub provenance: Option<Vec<ProvenanceRef>>,
210}
211
212/// `POST /api/content/compose` — compose manual content (tweet or thread).
213pub 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
237/// Handle tweet compose via the unified endpoint.
238async 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
256/// Handle legacy thread compose (content as JSON array of strings).
257async 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
289/// Handle structured thread blocks compose.
290async 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    // Collect per-block media into a flat list for approval queue storage.
309    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    // Validate scheduled_for early, before any branching logic
316    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        // User explicitly chose a future time — already validated above.
369        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        // Immediate publish — try posting as a reply chain.
394        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
426/// Persist content via approval queue, scheduled content, or post directly.
427async fn persist_content(
428    state: &AppState,
429    ctx: &AccountContext,
430    body: &ComposeRequest,
431    content: &str,
432) -> Result<Json<Value>, ApiError> {
433    // Validate scheduled_for early, before any branching logic
434    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        // User explicitly chose a future time — already validated above.
488        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        // Immediate publish — try posting via X API directly.
512        // If not configured for direct posting, save to calendar instead.
513        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
544/// Build an X API client for the given account based on the configured backend.
545///
546/// Returns `Box<dyn XApiClient>` so callers can use either scraper or OAuth
547/// without duplicating the construction logic.
548async 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
588/// Attempt to post a tweet directly via X API or cookie-auth transport.
589async 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
623/// Post a thread as a reply chain: first tweet standalone, each subsequent
624/// tweet replying to the previous one. Returns all posted tweet IDs.
625async 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                // Log partial failure with the IDs we did post.
648                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
700/// Build a `ProvenanceInput` from optional provenance refs.
701fn 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    // ── ThreadBlockRequest::into_core ──────────────────────────────
728
729    #[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    // ── build_provenance_input ────────────────────────────────────
775
776    #[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        // find_map returns first match
840        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        // source_chunks_json should be valid JSON
844        let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
845        assert_eq!(parsed.len(), 2);
846    }
847
848    // ── ComposeTweetRequest deserialization ────────────────────────
849
850    #[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    // ── ComposeThreadRequest deserialization ───────────────────────
876
877    #[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    // ── ComposeRequest deserialization ─────────────────────────────
886
887    #[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    // ── content_type routing ──────────────────────────────────────
928
929    #[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    // ── tweet length validation logic ─────────────────────────────
951
952    #[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    // ── legacy thread parsing logic ───────────────────────────────
977
978    #[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    // ── thread blocks to core conversion ──────────────────────────
1001
1002    #[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        // Sort by order to get block_ids in order
1028        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    // ── media_json serialization ──────────────────────────────────
1035
1036    #[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    // ── thread block validation integration ───────────────────────
1052
1053    #[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    // ── thread block validation edge cases ─────────────────────────
1098
1099    #[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        // Threads require at least 2 blocks
1109        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    // ── compose_tweet_request edge cases ───────────────────────────
1143
1144    #[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    // ── compose_request edge cases ─────────────────────────────────
1152
1153    #[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    // ── build_provenance_input detailed ────────────────────────────
1190
1191    #[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        // source_chunks_json should be valid JSON
1206        let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
1207        assert_eq!(parsed.len(), 1);
1208    }
1209
1210    // ── tweet_weighted_len boundary tests ──────────────────────────
1211
1212    #[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        // URLs count as 23 chars in X's weighted length
1221        let text = "Check out https://example.com/some/long/path/here";
1222        let len = tuitbot_core::content::tweet_weighted_len(text);
1223        // Should be less than the raw char count due to URL shortening
1224        assert!(len < text.len(), "URL should be shortened in weighted len");
1225    }
1226
1227    // ── thread block media aggregation ─────────────────────────────
1228
1229    #[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    // ── legacy thread content parsing ──────────────────────────────
1261
1262    #[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}