Skip to main content

tuitbot_server/routes/
approval.rs

1//! Approval queue endpoints.
2
3use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::config::Config;
10use tuitbot_core::storage::{action_log, approval_queue, scheduled_content};
11
12use crate::account::{require_approve, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15use crate::ws::{AccountWsEvent, WsEvent};
16
17/// Query parameters for listing approval items.
18#[derive(Deserialize)]
19pub struct ApprovalQuery {
20    /// Comma-separated status values (default: "pending").
21    #[serde(default = "default_status")]
22    pub status: String,
23    /// Filter by action type (reply, tweet, thread_tweet).
24    #[serde(rename = "type")]
25    pub action_type: Option<String>,
26    /// Filter by reviewer name.
27    pub reviewed_by: Option<String>,
28    /// Filter by items created since this ISO-8601 timestamp.
29    pub since: Option<String>,
30}
31
32fn default_status() -> String {
33    "pending".to_string()
34}
35
36/// `GET /api/approval` — list approval items with optional status/type/reviewer/date filters.
37pub async fn list_items(
38    State(state): State<Arc<AppState>>,
39    ctx: AccountContext,
40    Query(params): Query<ApprovalQuery>,
41) -> Result<Json<Value>, ApiError> {
42    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
43    let action_type = params.action_type.as_deref();
44    let reviewed_by = params.reviewed_by.as_deref();
45    let since = params.since.as_deref();
46
47    let items = approval_queue::get_filtered_for(
48        &state.db,
49        &ctx.account_id,
50        &statuses,
51        action_type,
52        reviewed_by,
53        since,
54    )
55    .await?;
56    Ok(Json(json!(items)))
57}
58
59/// `GET /api/approval/stats` — counts by status.
60pub async fn stats(
61    State(state): State<Arc<AppState>>,
62    ctx: AccountContext,
63) -> Result<Json<Value>, ApiError> {
64    let stats = approval_queue::get_stats_for(&state.db, &ctx.account_id).await?;
65    Ok(Json(json!(stats)))
66}
67
68/// Request body for editing approval item content.
69#[derive(Deserialize)]
70pub struct EditContentRequest {
71    pub content: String,
72    /// Optional updated media paths.
73    #[serde(default)]
74    pub media_paths: Option<Vec<String>>,
75    /// Who made the edit (default: "dashboard").
76    #[serde(default = "default_editor")]
77    pub editor: String,
78}
79
80fn default_editor() -> String {
81    "dashboard".to_string()
82}
83
84/// `PATCH /api/approval/:id` — edit content before approving.
85pub async fn edit_item(
86    State(state): State<Arc<AppState>>,
87    ctx: AccountContext,
88    Path(id): Path<i64>,
89    Json(body): Json<EditContentRequest>,
90) -> Result<Json<Value>, ApiError> {
91    require_approve(&ctx)?;
92
93    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
94    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
95
96    let content = body.content.trim();
97    if content.is_empty() {
98        return Err(ApiError::BadRequest("content cannot be empty".to_string()));
99    }
100
101    // Record edit history before updating (queries by PK, implicitly scoped).
102    if content != item.generated_content {
103        let _ = approval_queue::record_edit(
104            &state.db,
105            id,
106            &body.editor,
107            "generated_content",
108            &item.generated_content,
109            content,
110        )
111        .await;
112    }
113
114    approval_queue::update_content_for(&state.db, &ctx.account_id, id, content).await?;
115
116    if let Some(media_paths) = &body.media_paths {
117        let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
118
119        // Record media_paths edit if changed.
120        if media_json != item.media_paths {
121            let _ = approval_queue::record_edit(
122                &state.db,
123                id,
124                &body.editor,
125                "media_paths",
126                &item.media_paths,
127                &media_json,
128            )
129            .await;
130        }
131
132        approval_queue::update_media_paths_for(&state.db, &ctx.account_id, id, &media_json).await?;
133    }
134
135    // Log to action log.
136    let metadata = json!({
137        "approval_id": id,
138        "editor": body.editor,
139        "field": "generated_content",
140    });
141    let _ = action_log::log_action_for(
142        &state.db,
143        &ctx.account_id,
144        "approval_edited",
145        "success",
146        Some(&format!("Edited approval item {id}")),
147        Some(&metadata.to_string()),
148    )
149    .await;
150
151    let updated = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id)
152        .await?
153        .expect("item was just verified to exist");
154    Ok(Json(json!(updated)))
155}
156
157/// `POST /api/approval/:id/approve` — approve a queued item.
158pub async fn approve_item(
159    State(state): State<Arc<AppState>>,
160    ctx: AccountContext,
161    Path(id): Path<i64>,
162    body: Option<Json<approval_queue::ReviewAction>>,
163) -> Result<Json<Value>, ApiError> {
164    require_approve(&ctx)?;
165
166    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
167    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
168
169    // Safety guard: only allow approval if status is "pending"
170    if item.status != "pending" {
171        return Err(ApiError::Conflict(format!(
172            "cannot approve item {id}: status is '{}', expected 'pending'",
173            item.status
174        )));
175    }
176
177    // Verify X auth tokens exist before allowing approval.
178    let token_path =
179        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
180    if !token_path.exists() {
181        return Err(ApiError::BadRequest(
182            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
183        ));
184    }
185
186    let review = body.map(|b| b.0).unwrap_or_default();
187
188    // Check if this item has a future scheduling intent.
189    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
190        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
191            .ok()
192            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
193            .map(|_| sched.to_string())
194    });
195
196    if let Some(ref sched) = schedule_bridge {
197        // Approve and mark as "scheduled" — the posting engine only picks up "approved" items,
198        // so "scheduled" prevents double-posting.
199        approval_queue::update_status_with_review_for(
200            &state.db,
201            &ctx.account_id,
202            id,
203            "scheduled",
204            &review,
205        )
206        .await?;
207
208        // Bridge to scheduled_content so the scheduler posts at the intended time.
209        let sc_id = scheduled_content::insert_for(
210            &state.db,
211            &ctx.account_id,
212            &item.action_type,
213            &item.generated_content,
214            Some(sched),
215        )
216        .await?;
217
218        let metadata = json!({
219            "approval_id": id,
220            "scheduled_content_id": sc_id,
221            "scheduled_for": sched,
222            "actor": review.actor,
223            "notes": review.notes,
224            "action_type": item.action_type,
225        });
226        let _ = action_log::log_action_for(
227            &state.db,
228            &ctx.account_id,
229            "approval_approved_scheduled",
230            "success",
231            Some(&format!("Approved item {id} → scheduled for {sched}")),
232            Some(&metadata.to_string()),
233        )
234        .await;
235
236        let _ = state.event_tx.send(AccountWsEvent {
237            account_id: ctx.account_id.clone(),
238            event: WsEvent::ApprovalUpdated {
239                id,
240                status: "scheduled".to_string(),
241                action_type: item.action_type,
242                actor: review.actor,
243            },
244        });
245
246        return Ok(Json(json!({
247            "status": "scheduled",
248            "id": id,
249            "scheduled_content_id": sc_id,
250            "scheduled_for": sched,
251        })));
252    }
253
254    // No scheduling intent (or scheduled_for is in the past) — approve for immediate posting.
255    approval_queue::update_status_with_review_for(
256        &state.db,
257        &ctx.account_id,
258        id,
259        "approved",
260        &review,
261    )
262    .await?;
263
264    // Log to action log.
265    let metadata = json!({
266        "approval_id": id,
267        "actor": review.actor,
268        "notes": review.notes,
269        "action_type": item.action_type,
270    });
271    let _ = action_log::log_action_for(
272        &state.db,
273        &ctx.account_id,
274        "approval_approved",
275        "success",
276        Some(&format!("Approved item {id}")),
277        Some(&metadata.to_string()),
278    )
279    .await;
280
281    let _ = state.event_tx.send(AccountWsEvent {
282        account_id: ctx.account_id.clone(),
283        event: WsEvent::ApprovalUpdated {
284            id,
285            status: "approved".to_string(),
286            action_type: item.action_type,
287            actor: review.actor,
288        },
289    });
290
291    Ok(Json(json!({"status": "approved", "id": id})))
292}
293
294/// `POST /api/approval/:id/reject` — reject a queued item.
295pub async fn reject_item(
296    State(state): State<Arc<AppState>>,
297    ctx: AccountContext,
298    Path(id): Path<i64>,
299    body: Option<Json<approval_queue::ReviewAction>>,
300) -> Result<Json<Value>, ApiError> {
301    require_approve(&ctx)?;
302
303    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
304    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
305
306    // Safety guard: only allow rejection if status is "pending"
307    if item.status != "pending" {
308        return Err(ApiError::Conflict(format!(
309            "cannot reject item {id}: status is '{}', expected 'pending'",
310            item.status
311        )));
312    }
313
314    let review = body.map(|b| b.0).unwrap_or_default();
315    approval_queue::update_status_with_review_for(
316        &state.db,
317        &ctx.account_id,
318        id,
319        "rejected",
320        &review,
321    )
322    .await?;
323
324    // Log to action log.
325    let metadata = json!({
326        "approval_id": id,
327        "actor": review.actor,
328        "notes": review.notes,
329        "action_type": item.action_type,
330    });
331    let _ = action_log::log_action_for(
332        &state.db,
333        &ctx.account_id,
334        "approval_rejected",
335        "success",
336        Some(&format!("Rejected item {id}")),
337        Some(&metadata.to_string()),
338    )
339    .await;
340
341    let _ = state.event_tx.send(AccountWsEvent {
342        account_id: ctx.account_id.clone(),
343        event: WsEvent::ApprovalUpdated {
344            id,
345            status: "rejected".to_string(),
346            action_type: item.action_type,
347            actor: review.actor,
348        },
349    });
350
351    Ok(Json(json!({"status": "rejected", "id": id})))
352}
353
354/// Request body for batch approve.
355#[derive(Deserialize)]
356pub struct BatchApproveRequest {
357    /// Maximum number of items to approve (clamped to server config).
358    #[serde(default)]
359    pub max: Option<usize>,
360    /// Specific IDs to approve (if provided, `max` is ignored).
361    #[serde(default)]
362    pub ids: Option<Vec<i64>>,
363    /// Review metadata.
364    #[serde(default)]
365    pub review: approval_queue::ReviewAction,
366}
367
368/// `POST /api/approval/approve-all` — batch-approve pending items.
369pub async fn approve_all(
370    State(state): State<Arc<AppState>>,
371    ctx: AccountContext,
372    body: Option<Json<BatchApproveRequest>>,
373) -> Result<Json<Value>, ApiError> {
374    require_approve(&ctx)?;
375
376    // Verify X auth tokens exist before allowing approval.
377    let token_path =
378        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
379    if !token_path.exists() {
380        return Err(ApiError::BadRequest(
381            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
382        ));
383    }
384
385    let config = read_config(&state);
386    let max_batch = config.max_batch_approve;
387
388    let body = body.map(|b| b.0);
389    let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
390
391    let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
392        // Approve specific IDs (still clamped to max_batch).
393        let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
394        let mut approved = Vec::with_capacity(clamped.len());
395        for &id in &clamped {
396            if let Ok(Some(item)) =
397                approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
398            {
399                let result = approve_single_item(&state, &ctx.account_id, &item, &review).await;
400                if result.is_ok() {
401                    approved.push(*id);
402                }
403            }
404        }
405        approved
406    } else {
407        // Approve oldest N pending items, handling scheduling intent per-item.
408        let effective_max = body
409            .as_ref()
410            .and_then(|b| b.max)
411            .map(|m| m.min(max_batch))
412            .unwrap_or(max_batch);
413
414        let pending = approval_queue::get_pending_for(&state.db, &ctx.account_id).await?;
415        let mut approved = Vec::with_capacity(effective_max);
416        for item in pending.iter().take(effective_max) {
417            if approve_single_item(&state, &ctx.account_id, item, &review)
418                .await
419                .is_ok()
420            {
421                approved.push(item.id);
422            }
423        }
424        approved
425    };
426
427    let count = approved_ids.len();
428
429    // Log to action log.
430    let metadata = json!({
431        "count": count,
432        "ids": approved_ids,
433        "actor": review.actor,
434        "max_configured": max_batch,
435    });
436    let _ = action_log::log_action_for(
437        &state.db,
438        &ctx.account_id,
439        "approval_batch_approved",
440        "success",
441        Some(&format!("Batch approved {count} items")),
442        Some(&metadata.to_string()),
443    )
444    .await;
445
446    let _ = state.event_tx.send(AccountWsEvent {
447        account_id: ctx.account_id.clone(),
448        event: WsEvent::ApprovalUpdated {
449            id: 0,
450            status: "approved_all".to_string(),
451            action_type: String::new(),
452            actor: review.actor,
453        },
454    });
455
456    Ok(Json(
457        json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
458    ))
459}
460
461/// Query parameters for the approval export endpoint.
462#[derive(Deserialize)]
463pub struct ExportQuery {
464    /// Export format: "csv" or "json" (default: "csv").
465    #[serde(default = "default_csv")]
466    pub format: String,
467    /// Comma-separated status values (default: all).
468    #[serde(default = "default_export_status")]
469    pub status: String,
470    /// Filter by action type.
471    #[serde(rename = "type")]
472    pub action_type: Option<String>,
473}
474
475fn default_csv() -> String {
476    "csv".to_string()
477}
478
479fn default_export_status() -> String {
480    "pending,approved,rejected,posted".to_string()
481}
482
483/// `GET /api/approval/export` — export approval items as CSV or JSON.
484pub async fn export_items(
485    State(state): State<Arc<AppState>>,
486    ctx: AccountContext,
487    Query(params): Query<ExportQuery>,
488) -> Result<axum::response::Response, ApiError> {
489    use axum::response::IntoResponse;
490
491    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
492    let action_type = params.action_type.as_deref();
493
494    let items =
495        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &statuses, action_type)
496            .await?;
497
498    if params.format == "json" {
499        let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
500        Ok((
501            [
502                (
503                    axum::http::header::CONTENT_TYPE,
504                    "application/json; charset=utf-8",
505                ),
506                (
507                    axum::http::header::CONTENT_DISPOSITION,
508                    "attachment; filename=\"approval_export.json\"",
509                ),
510            ],
511            body,
512        )
513            .into_response())
514    } else {
515        let mut csv = String::from(
516            "id,action_type,target_author,generated_content,topic,score,status,reviewed_by,review_notes,created_at\n",
517        );
518        for item in &items {
519            csv.push_str(&format!(
520                "{},{},{},{},{},{},{},{},{},{}\n",
521                item.id,
522                escape_csv(&item.action_type),
523                escape_csv(&item.target_author),
524                escape_csv(&item.generated_content),
525                escape_csv(&item.topic),
526                item.score,
527                escape_csv(&item.status),
528                escape_csv(item.reviewed_by.as_deref().unwrap_or("")),
529                escape_csv(item.review_notes.as_deref().unwrap_or("")),
530                escape_csv(&item.created_at),
531            ));
532        }
533        Ok((
534            [
535                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
536                (
537                    axum::http::header::CONTENT_DISPOSITION,
538                    "attachment; filename=\"approval_export.csv\"",
539                ),
540            ],
541            csv,
542        )
543            .into_response())
544    }
545}
546
547/// Escape a value for CSV output.
548fn escape_csv(value: &str) -> String {
549    if value.contains(',') || value.contains('"') || value.contains('\n') {
550        format!("\"{}\"", value.replace('"', "\"\""))
551    } else {
552        value.to_string()
553    }
554}
555
556/// `GET /api/approval/:id/history` — get edit history for an item.
557pub async fn get_edit_history(
558    State(state): State<Arc<AppState>>,
559    _ctx: AccountContext,
560    Path(id): Path<i64>,
561) -> Result<Json<Value>, ApiError> {
562    // Query by approval_id PK is already implicitly scoped.
563    let history = approval_queue::get_edit_history(&state.db, id).await?;
564    Ok(Json(json!(history)))
565}
566
567/// Approve a single item, bridging to scheduled_content if it has a future `scheduled_for`.
568async fn approve_single_item(
569    state: &AppState,
570    account_id: &str,
571    item: &approval_queue::ApprovalItem,
572    review: &approval_queue::ReviewAction,
573) -> Result<(), ApiError> {
574    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
575        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
576            .ok()
577            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
578            .map(|_| sched.to_string())
579    });
580
581    if let Some(ref sched) = schedule_bridge {
582        approval_queue::update_status_with_review_for(
583            &state.db,
584            account_id,
585            item.id,
586            "scheduled",
587            review,
588        )
589        .await?;
590
591        scheduled_content::insert_for(
592            &state.db,
593            account_id,
594            &item.action_type,
595            &item.generated_content,
596            Some(sched),
597        )
598        .await?;
599    } else {
600        approval_queue::update_status_with_review_for(
601            &state.db, account_id, item.id, "approved", review,
602        )
603        .await?;
604    }
605
606    Ok(())
607}
608
609/// Read the config from disk (best-effort, returns defaults on failure).
610fn read_config(state: &AppState) -> Config {
611    std::fs::read_to_string(&state.config_path)
612        .ok()
613        .and_then(|s| toml::from_str(&s).ok())
614        .unwrap_or_default()
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn escape_csv_no_special_chars() {
623        assert_eq!(escape_csv("hello"), "hello");
624        assert_eq!(escape_csv("simple text"), "simple text");
625    }
626
627    #[test]
628    fn escape_csv_with_comma() {
629        assert_eq!(escape_csv("hello, world"), "\"hello, world\"");
630    }
631
632    #[test]
633    fn escape_csv_with_quotes() {
634        assert_eq!(escape_csv(r#"say "hi""#), r#""say ""hi""""#);
635    }
636
637    #[test]
638    fn escape_csv_with_newline() {
639        assert_eq!(escape_csv("line1\nline2"), "\"line1\nline2\"");
640    }
641
642    #[test]
643    fn escape_csv_empty() {
644        assert_eq!(escape_csv(""), "");
645    }
646
647    #[test]
648    fn escape_csv_with_all_special() {
649        let result = escape_csv("a,b\"c\nd");
650        assert!(result.starts_with('"'));
651        assert!(result.ends_with('"'));
652    }
653
654    #[test]
655    fn default_status_is_pending() {
656        assert_eq!(default_status(), "pending");
657    }
658
659    #[test]
660    fn default_editor_is_dashboard() {
661        assert_eq!(default_editor(), "dashboard");
662    }
663
664    #[test]
665    fn default_csv_is_csv() {
666        assert_eq!(default_csv(), "csv");
667    }
668
669    #[test]
670    fn default_export_status_includes_all() {
671        let status = default_export_status();
672        assert!(status.contains("pending"));
673        assert!(status.contains("approved"));
674        assert!(status.contains("rejected"));
675        assert!(status.contains("posted"));
676    }
677
678    #[test]
679    fn approval_query_deserialize_defaults() {
680        let json = r#"{}"#;
681        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
682        assert_eq!(query.status, "pending");
683        assert!(query.action_type.is_none());
684        assert!(query.reviewed_by.is_none());
685        assert!(query.since.is_none());
686    }
687
688    #[test]
689    fn approval_query_deserialize_with_type() {
690        let json = r#"{"type": "reply"}"#;
691        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
692        assert_eq!(query.action_type.as_deref(), Some("reply"));
693    }
694
695    #[test]
696    fn edit_content_request_deserialize() {
697        let json = r#"{"content": "new text"}"#;
698        let req: EditContentRequest = serde_json::from_str(json).unwrap();
699        assert_eq!(req.content, "new text");
700        assert!(req.media_paths.is_none());
701        assert_eq!(req.editor, "dashboard");
702    }
703
704    #[test]
705    fn edit_content_request_with_media() {
706        let json = r#"{"content": "text", "media_paths": ["a.png"], "editor": "cli"}"#;
707        let req: EditContentRequest = serde_json::from_str(json).unwrap();
708        assert_eq!(req.media_paths.as_ref().unwrap().len(), 1);
709        assert_eq!(req.editor, "cli");
710    }
711
712    #[test]
713    fn batch_approve_request_deserialize_defaults() {
714        let json = r#"{}"#;
715        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
716        assert!(req.max.is_none());
717        assert!(req.ids.is_none());
718        assert!(req.review.actor.is_none());
719    }
720
721    #[test]
722    fn batch_approve_request_with_ids() {
723        let json = r#"{"ids": [1, 2, 3], "review": {"actor": "admin"}}"#;
724        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
725        assert_eq!(req.ids.as_ref().unwrap().len(), 3);
726        assert_eq!(req.review.actor.as_deref(), Some("admin"));
727    }
728
729    #[test]
730    fn export_query_deserialize_defaults() {
731        let json = r#"{}"#;
732        let query: ExportQuery = serde_json::from_str(json).unwrap();
733        assert_eq!(query.format, "csv");
734        assert!(query.status.contains("pending"));
735        assert!(query.action_type.is_none());
736    }
737
738    #[test]
739    fn export_query_json_format() {
740        let json = r#"{"format": "json", "type": "tweet"}"#;
741        let query: ExportQuery = serde_json::from_str(json).unwrap();
742        assert_eq!(query.format, "json");
743        assert_eq!(query.action_type.as_deref(), Some("tweet"));
744    }
745
746    // -----------------------------------------------------------------------
747    // Extended approval helper tests for coverage push
748    // -----------------------------------------------------------------------
749
750    #[test]
751    fn escape_csv_tab_character() {
752        // Tab is not a special CSV char in our impl
753        assert_eq!(escape_csv("hello\tworld"), "hello\tworld");
754    }
755
756    #[test]
757    fn escape_csv_only_comma() {
758        let result = escape_csv(",");
759        assert_eq!(result, r#"",""#);
760    }
761
762    #[test]
763    fn escape_csv_only_quote() {
764        let result = escape_csv(r#"""#);
765        assert_eq!(result, r#""""""#);
766    }
767
768    #[test]
769    fn escape_csv_only_newline() {
770        let result = escape_csv("\n");
771        assert_eq!(result, "\"\n\"");
772    }
773
774    #[test]
775    fn escape_csv_mixed_special_chars() {
776        let result = escape_csv("a,b\nc\"d");
777        assert!(result.starts_with('"'));
778        assert!(result.ends_with('"'));
779        assert!(result.contains("\"\""));
780    }
781
782    #[test]
783    fn escape_csv_long_text() {
784        let text = "a".repeat(1000);
785        let result = escape_csv(&text);
786        assert_eq!(result, text); // no special chars
787    }
788
789    #[test]
790    fn escape_csv_unicode() {
791        assert_eq!(escape_csv("caf\u{00E9}"), "caf\u{00E9}");
792        assert_eq!(escape_csv("\u{1F600}"), "\u{1F600}");
793    }
794
795    #[test]
796    fn approval_query_deserialize_with_all_fields() {
797        let json = r#"{
798            "status": "approved,rejected",
799            "type": "tweet",
800            "reviewed_by": "admin",
801            "since": "2026-01-01T00:00:00Z"
802        }"#;
803        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
804        assert_eq!(query.status, "approved,rejected");
805        assert_eq!(query.action_type.as_deref(), Some("tweet"));
806        assert_eq!(query.reviewed_by.as_deref(), Some("admin"));
807        assert_eq!(query.since.as_deref(), Some("2026-01-01T00:00:00Z"));
808    }
809
810    #[test]
811    fn approval_query_status_split() {
812        let json = r#"{"status": "pending,approved,rejected"}"#;
813        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
814        let statuses: Vec<&str> = query.status.split(',').map(|s| s.trim()).collect();
815        assert_eq!(statuses.len(), 3);
816        assert_eq!(statuses[0], "pending");
817        assert_eq!(statuses[1], "approved");
818        assert_eq!(statuses[2], "rejected");
819    }
820
821    #[test]
822    fn edit_content_request_empty_media_paths() {
823        let json = r#"{"content": "text", "media_paths": []}"#;
824        let req: EditContentRequest = serde_json::from_str(json).unwrap();
825        assert!(req.media_paths.as_ref().unwrap().is_empty());
826    }
827
828    #[test]
829    fn edit_content_request_multiple_media() {
830        let json = r#"{"content": "text", "media_paths": ["a.png", "b.jpg", "c.gif"]}"#;
831        let req: EditContentRequest = serde_json::from_str(json).unwrap();
832        assert_eq!(req.media_paths.as_ref().unwrap().len(), 3);
833    }
834
835    #[test]
836    fn batch_approve_request_with_max() {
837        let json = r#"{"max": 10}"#;
838        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
839        assert_eq!(req.max, Some(10));
840        assert!(req.ids.is_none());
841    }
842
843    #[test]
844    fn batch_approve_request_with_review_notes() {
845        let json = r#"{"review": {"actor": "admin", "notes": "LGTM"}}"#;
846        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
847        assert_eq!(req.review.actor.as_deref(), Some("admin"));
848        assert_eq!(req.review.notes.as_deref(), Some("LGTM"));
849    }
850
851    #[test]
852    fn batch_approve_request_empty_ids() {
853        let json = r#"{"ids": []}"#;
854        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
855        assert!(req.ids.as_ref().unwrap().is_empty());
856    }
857
858    #[test]
859    fn export_query_custom_status() {
860        let json = r#"{"status": "posted"}"#;
861        let query: ExportQuery = serde_json::from_str(json).unwrap();
862        assert_eq!(query.status, "posted");
863        assert_eq!(query.format, "csv"); // default
864    }
865
866    #[test]
867    fn export_query_with_type_filter() {
868        let json = r#"{"type": "thread_tweet"}"#;
869        let query: ExportQuery = serde_json::from_str(json).unwrap();
870        assert_eq!(query.action_type.as_deref(), Some("thread_tweet"));
871    }
872
873    #[test]
874    fn default_status_value_check() {
875        let s = default_status();
876        assert_eq!(s, "pending");
877        assert!(!s.is_empty());
878    }
879
880    #[test]
881    fn default_editor_value_check() {
882        let e = default_editor();
883        assert_eq!(e, "dashboard");
884        assert!(!e.is_empty());
885    }
886
887    #[test]
888    fn default_csv_value_check() {
889        let c = default_csv();
890        assert_eq!(c, "csv");
891        assert!(!c.is_empty());
892    }
893
894    #[test]
895    fn default_export_status_contains_all_four() {
896        let s = default_export_status();
897        let parts: Vec<&str> = s.split(',').collect();
898        assert_eq!(parts.len(), 4);
899        assert!(parts.contains(&"pending"));
900        assert!(parts.contains(&"approved"));
901        assert!(parts.contains(&"rejected"));
902        assert!(parts.contains(&"posted"));
903    }
904
905    // -----------------------------------------------------------------------
906    // Status guard tests
907    // -----------------------------------------------------------------------
908
909    /// Verify status guard prevents approving already-approved items.
910    #[test]
911    fn status_guard_approve_rejects_non_pending() {
912        // Simulate checking item.status in approve_item
913        let item_status = "approved";
914        let is_valid_for_approval = item_status == "pending";
915        assert!(!is_valid_for_approval);
916    }
917
918    /// Verify status guard prevents approving already-rejected items.
919    #[test]
920    fn status_guard_approve_rejects_rejected_status() {
921        let item_status = "rejected";
922        let is_valid_for_approval = item_status == "pending";
923        assert!(!is_valid_for_approval);
924    }
925
926    /// Verify status guard allows approval of pending items.
927    #[test]
928    fn status_guard_approve_accepts_pending() {
929        let item_status = "pending";
930        let is_valid_for_approval = item_status == "pending";
931        assert!(is_valid_for_approval);
932    }
933
934    /// Verify status guard prevents rejecting already-approved items.
935    #[test]
936    fn status_guard_reject_rejects_approved_status() {
937        let item_status = "approved";
938        let is_valid_for_rejection = item_status == "pending";
939        assert!(!is_valid_for_rejection);
940    }
941
942    /// Verify status guard prevents rejecting already-rejected items.
943    #[test]
944    fn status_guard_reject_rejects_already_rejected() {
945        let item_status = "rejected";
946        let is_valid_for_rejection = item_status == "pending";
947        assert!(!is_valid_for_rejection);
948    }
949
950    /// Verify status guard allows rejection of pending items.
951    #[test]
952    fn status_guard_reject_accepts_pending() {
953        let item_status = "pending";
954        let is_valid_for_rejection = item_status == "pending";
955        assert!(is_valid_for_rejection);
956    }
957
958    /// Verify status guard rejects scheduled items (cannot re-approve/re-reject).
959    #[test]
960    fn status_guard_prevents_action_on_scheduled() {
961        let item_status = "scheduled";
962        assert_ne!(item_status, "pending");
963        assert!(!item_status.is_empty());
964    }
965
966    #[test]
967    fn escape_csv_preserves_spaces() {
968        assert_eq!(escape_csv("hello world"), "hello world");
969        assert_eq!(escape_csv("  leading"), "  leading");
970    }
971
972    #[test]
973    fn escape_csv_carriage_return() {
974        // \r alone is not a trigger in our impl
975        assert_eq!(escape_csv("hello\rworld"), "hello\rworld");
976    }
977
978    #[test]
979    fn escape_csv_double_quotes_escaped() {
980        let result = escape_csv(r#"say "hello" to "world""#);
981        assert!(result.starts_with('"'));
982        assert!(result.ends_with('"'));
983        // Each " becomes ""
984        assert!(result.contains(r#""""#));
985    }
986
987    #[test]
988    fn approval_query_type_reply() {
989        let json = r#"{"type": "reply"}"#;
990        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
991        assert_eq!(query.action_type, Some("reply".to_string()));
992    }
993
994    #[test]
995    fn approval_query_type_thread_tweet() {
996        let json = r#"{"type": "thread_tweet"}"#;
997        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
998        assert_eq!(query.action_type, Some("thread_tweet".to_string()));
999    }
1000
1001    #[test]
1002    fn edit_content_request_custom_editor() {
1003        let json = r#"{"content": "test", "editor": "api"}"#;
1004        let req: EditContentRequest = serde_json::from_str(json).unwrap();
1005        assert_eq!(req.editor, "api");
1006    }
1007
1008    #[test]
1009    fn batch_approve_request_large_ids_list() {
1010        let ids: Vec<i64> = (1..=100).collect();
1011        let json = serde_json::json!({"ids": ids});
1012        let req: BatchApproveRequest = serde_json::from_str(&json.to_string()).unwrap();
1013        assert_eq!(req.ids.as_ref().unwrap().len(), 100);
1014    }
1015}