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            // Use the shared health handle from AppState so each request's outcome
559            // aggregates into the tracker that /health reads, rather than being discarded.
560            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
599/// Attempt to post a tweet directly via X API or cookie-auth transport.
600async 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
634/// Post a thread as a reply chain: first tweet standalone, each subsequent
635/// tweet replying to the previous one. Returns all posted tweet IDs.
636async 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                // Log partial failure with the IDs we did post.
659                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
711/// Build a `ProvenanceInput` from optional provenance refs.
712fn 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    // ── ThreadBlockRequest::into_core ──────────────────────────────
739
740    #[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    // ── build_provenance_input ────────────────────────────────────
786
787    #[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        // find_map returns first match
851        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        // source_chunks_json should be valid JSON
855        let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
856        assert_eq!(parsed.len(), 2);
857    }
858
859    // ── ComposeTweetRequest deserialization ────────────────────────
860
861    #[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    // ── ComposeThreadRequest deserialization ───────────────────────
887
888    #[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    // ── ComposeRequest deserialization ─────────────────────────────
897
898    #[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    // ── content_type routing ──────────────────────────────────────
939
940    #[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    // ── tweet length validation logic ─────────────────────────────
962
963    #[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    // ── legacy thread parsing logic ───────────────────────────────
988
989    #[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    // ── thread blocks to core conversion ──────────────────────────
1012
1013    #[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        // Sort by order to get block_ids in order
1039        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    // ── media_json serialization ──────────────────────────────────
1046
1047    #[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    // ── thread block validation integration ───────────────────────
1063
1064    #[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    // ── thread block validation edge cases ─────────────────────────
1109
1110    #[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        // Threads require at least 2 blocks
1120        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    // ── compose_tweet_request edge cases ───────────────────────────
1154
1155    #[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    // ── compose_request edge cases ─────────────────────────────────
1163
1164    #[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    // ── build_provenance_input detailed ────────────────────────────
1201
1202    #[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        // source_chunks_json should be valid JSON
1217        let parsed: Vec<ProvenanceRef> = serde_json::from_str(&result.source_chunks_json).unwrap();
1218        assert_eq!(parsed.len(), 1);
1219    }
1220
1221    // ── tweet_weighted_len boundary tests ──────────────────────────
1222
1223    #[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        // URLs count as 23 chars in X's weighted length
1232        let text = "Check out https://example.com/some/long/path/here";
1233        let len = tuitbot_core::content::tweet_weighted_len(text);
1234        // Should be less than the raw char count due to URL shortening
1235        assert!(len < text.len(), "URL should be shortened in weighted len");
1236    }
1237
1238    // ── thread block media aggregation ─────────────────────────────
1239
1240    #[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    // ── legacy thread content parsing ──────────────────────────────
1272
1273    #[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}