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    // Verify X auth tokens exist before allowing approval.
170    let token_path =
171        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
172    if !token_path.exists() {
173        return Err(ApiError::BadRequest(
174            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
175        ));
176    }
177
178    let review = body.map(|b| b.0).unwrap_or_default();
179
180    // Check if this item has a future scheduling intent.
181    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
182        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
183            .ok()
184            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
185            .map(|_| sched.to_string())
186    });
187
188    if let Some(ref sched) = schedule_bridge {
189        // Approve and mark as "scheduled" — the posting engine only picks up "approved" items,
190        // so "scheduled" prevents double-posting.
191        approval_queue::update_status_with_review_for(
192            &state.db,
193            &ctx.account_id,
194            id,
195            "scheduled",
196            &review,
197        )
198        .await?;
199
200        // Bridge to scheduled_content so the scheduler posts at the intended time.
201        let sc_id = scheduled_content::insert_for(
202            &state.db,
203            &ctx.account_id,
204            &item.action_type,
205            &item.generated_content,
206            Some(sched),
207        )
208        .await?;
209
210        let metadata = json!({
211            "approval_id": id,
212            "scheduled_content_id": sc_id,
213            "scheduled_for": sched,
214            "actor": review.actor,
215            "notes": review.notes,
216            "action_type": item.action_type,
217        });
218        let _ = action_log::log_action_for(
219            &state.db,
220            &ctx.account_id,
221            "approval_approved_scheduled",
222            "success",
223            Some(&format!("Approved item {id} → scheduled for {sched}")),
224            Some(&metadata.to_string()),
225        )
226        .await;
227
228        let _ = state.event_tx.send(AccountWsEvent {
229            account_id: ctx.account_id.clone(),
230            event: WsEvent::ApprovalUpdated {
231                id,
232                status: "scheduled".to_string(),
233                action_type: item.action_type,
234                actor: review.actor,
235            },
236        });
237
238        return Ok(Json(json!({
239            "status": "scheduled",
240            "id": id,
241            "scheduled_content_id": sc_id,
242            "scheduled_for": sched,
243        })));
244    }
245
246    // No scheduling intent (or scheduled_for is in the past) — approve for immediate posting.
247    approval_queue::update_status_with_review_for(
248        &state.db,
249        &ctx.account_id,
250        id,
251        "approved",
252        &review,
253    )
254    .await?;
255
256    // Log to action log.
257    let metadata = json!({
258        "approval_id": id,
259        "actor": review.actor,
260        "notes": review.notes,
261        "action_type": item.action_type,
262    });
263    let _ = action_log::log_action_for(
264        &state.db,
265        &ctx.account_id,
266        "approval_approved",
267        "success",
268        Some(&format!("Approved item {id}")),
269        Some(&metadata.to_string()),
270    )
271    .await;
272
273    let _ = state.event_tx.send(AccountWsEvent {
274        account_id: ctx.account_id.clone(),
275        event: WsEvent::ApprovalUpdated {
276            id,
277            status: "approved".to_string(),
278            action_type: item.action_type,
279            actor: review.actor,
280        },
281    });
282
283    Ok(Json(json!({"status": "approved", "id": id})))
284}
285
286/// `POST /api/approval/:id/reject` — reject a queued item.
287pub async fn reject_item(
288    State(state): State<Arc<AppState>>,
289    ctx: AccountContext,
290    Path(id): Path<i64>,
291    body: Option<Json<approval_queue::ReviewAction>>,
292) -> Result<Json<Value>, ApiError> {
293    require_approve(&ctx)?;
294
295    let item = approval_queue::get_by_id_for(&state.db, &ctx.account_id, id).await?;
296    let item = item.ok_or_else(|| ApiError::NotFound(format!("approval item {id} not found")))?;
297
298    let review = body.map(|b| b.0).unwrap_or_default();
299    approval_queue::update_status_with_review_for(
300        &state.db,
301        &ctx.account_id,
302        id,
303        "rejected",
304        &review,
305    )
306    .await?;
307
308    // Log to action log.
309    let metadata = json!({
310        "approval_id": id,
311        "actor": review.actor,
312        "notes": review.notes,
313        "action_type": item.action_type,
314    });
315    let _ = action_log::log_action_for(
316        &state.db,
317        &ctx.account_id,
318        "approval_rejected",
319        "success",
320        Some(&format!("Rejected item {id}")),
321        Some(&metadata.to_string()),
322    )
323    .await;
324
325    let _ = state.event_tx.send(AccountWsEvent {
326        account_id: ctx.account_id.clone(),
327        event: WsEvent::ApprovalUpdated {
328            id,
329            status: "rejected".to_string(),
330            action_type: item.action_type,
331            actor: review.actor,
332        },
333    });
334
335    Ok(Json(json!({"status": "rejected", "id": id})))
336}
337
338/// Request body for batch approve.
339#[derive(Deserialize)]
340pub struct BatchApproveRequest {
341    /// Maximum number of items to approve (clamped to server config).
342    #[serde(default)]
343    pub max: Option<usize>,
344    /// Specific IDs to approve (if provided, `max` is ignored).
345    #[serde(default)]
346    pub ids: Option<Vec<i64>>,
347    /// Review metadata.
348    #[serde(default)]
349    pub review: approval_queue::ReviewAction,
350}
351
352/// `POST /api/approval/approve-all` — batch-approve pending items.
353pub async fn approve_all(
354    State(state): State<Arc<AppState>>,
355    ctx: AccountContext,
356    body: Option<Json<BatchApproveRequest>>,
357) -> Result<Json<Value>, ApiError> {
358    require_approve(&ctx)?;
359
360    // Verify X auth tokens exist before allowing approval.
361    let token_path =
362        tuitbot_core::storage::accounts::account_token_path(&state.data_dir, &ctx.account_id);
363    if !token_path.exists() {
364        return Err(ApiError::BadRequest(
365            "Cannot approve: X API not authenticated. Complete X auth setup first.".to_string(),
366        ));
367    }
368
369    let config = read_config(&state);
370    let max_batch = config.max_batch_approve;
371
372    let body = body.map(|b| b.0);
373    let review = body.as_ref().map(|b| b.review.clone()).unwrap_or_default();
374
375    let approved_ids = if let Some(ids) = body.as_ref().and_then(|b| b.ids.as_ref()) {
376        // Approve specific IDs (still clamped to max_batch).
377        let clamped: Vec<&i64> = ids.iter().take(max_batch).collect();
378        let mut approved = Vec::with_capacity(clamped.len());
379        for &id in &clamped {
380            if let Ok(Some(item)) =
381                approval_queue::get_by_id_for(&state.db, &ctx.account_id, *id).await
382            {
383                let result = approve_single_item(&state, &ctx.account_id, &item, &review).await;
384                if result.is_ok() {
385                    approved.push(*id);
386                }
387            }
388        }
389        approved
390    } else {
391        // Approve oldest N pending items, handling scheduling intent per-item.
392        let effective_max = body
393            .as_ref()
394            .and_then(|b| b.max)
395            .map(|m| m.min(max_batch))
396            .unwrap_or(max_batch);
397
398        let pending = approval_queue::get_pending_for(&state.db, &ctx.account_id).await?;
399        let mut approved = Vec::with_capacity(effective_max);
400        for item in pending.iter().take(effective_max) {
401            if approve_single_item(&state, &ctx.account_id, item, &review)
402                .await
403                .is_ok()
404            {
405                approved.push(item.id);
406            }
407        }
408        approved
409    };
410
411    let count = approved_ids.len();
412
413    // Log to action log.
414    let metadata = json!({
415        "count": count,
416        "ids": approved_ids,
417        "actor": review.actor,
418        "max_configured": max_batch,
419    });
420    let _ = action_log::log_action_for(
421        &state.db,
422        &ctx.account_id,
423        "approval_batch_approved",
424        "success",
425        Some(&format!("Batch approved {count} items")),
426        Some(&metadata.to_string()),
427    )
428    .await;
429
430    let _ = state.event_tx.send(AccountWsEvent {
431        account_id: ctx.account_id.clone(),
432        event: WsEvent::ApprovalUpdated {
433            id: 0,
434            status: "approved_all".to_string(),
435            action_type: String::new(),
436            actor: review.actor,
437        },
438    });
439
440    Ok(Json(
441        json!({"status": "approved", "count": count, "ids": approved_ids, "max_batch": max_batch}),
442    ))
443}
444
445/// Query parameters for the approval export endpoint.
446#[derive(Deserialize)]
447pub struct ExportQuery {
448    /// Export format: "csv" or "json" (default: "csv").
449    #[serde(default = "default_csv")]
450    pub format: String,
451    /// Comma-separated status values (default: all).
452    #[serde(default = "default_export_status")]
453    pub status: String,
454    /// Filter by action type.
455    #[serde(rename = "type")]
456    pub action_type: Option<String>,
457}
458
459fn default_csv() -> String {
460    "csv".to_string()
461}
462
463fn default_export_status() -> String {
464    "pending,approved,rejected,posted".to_string()
465}
466
467/// `GET /api/approval/export` — export approval items as CSV or JSON.
468pub async fn export_items(
469    State(state): State<Arc<AppState>>,
470    ctx: AccountContext,
471    Query(params): Query<ExportQuery>,
472) -> Result<axum::response::Response, ApiError> {
473    use axum::response::IntoResponse;
474
475    let statuses: Vec<&str> = params.status.split(',').map(|s| s.trim()).collect();
476    let action_type = params.action_type.as_deref();
477
478    let items =
479        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &statuses, action_type)
480            .await?;
481
482    if params.format == "json" {
483        let body = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string());
484        Ok((
485            [
486                (
487                    axum::http::header::CONTENT_TYPE,
488                    "application/json; charset=utf-8",
489                ),
490                (
491                    axum::http::header::CONTENT_DISPOSITION,
492                    "attachment; filename=\"approval_export.json\"",
493                ),
494            ],
495            body,
496        )
497            .into_response())
498    } else {
499        let mut csv = String::from(
500            "id,action_type,target_author,generated_content,topic,score,status,reviewed_by,review_notes,created_at\n",
501        );
502        for item in &items {
503            csv.push_str(&format!(
504                "{},{},{},{},{},{},{},{},{},{}\n",
505                item.id,
506                escape_csv(&item.action_type),
507                escape_csv(&item.target_author),
508                escape_csv(&item.generated_content),
509                escape_csv(&item.topic),
510                item.score,
511                escape_csv(&item.status),
512                escape_csv(item.reviewed_by.as_deref().unwrap_or("")),
513                escape_csv(item.review_notes.as_deref().unwrap_or("")),
514                escape_csv(&item.created_at),
515            ));
516        }
517        Ok((
518            [
519                (axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
520                (
521                    axum::http::header::CONTENT_DISPOSITION,
522                    "attachment; filename=\"approval_export.csv\"",
523                ),
524            ],
525            csv,
526        )
527            .into_response())
528    }
529}
530
531/// Escape a value for CSV output.
532fn escape_csv(value: &str) -> String {
533    if value.contains(',') || value.contains('"') || value.contains('\n') {
534        format!("\"{}\"", value.replace('"', "\"\""))
535    } else {
536        value.to_string()
537    }
538}
539
540/// `GET /api/approval/:id/history` — get edit history for an item.
541pub async fn get_edit_history(
542    State(state): State<Arc<AppState>>,
543    _ctx: AccountContext,
544    Path(id): Path<i64>,
545) -> Result<Json<Value>, ApiError> {
546    // Query by approval_id PK is already implicitly scoped.
547    let history = approval_queue::get_edit_history(&state.db, id).await?;
548    Ok(Json(json!(history)))
549}
550
551/// Approve a single item, bridging to scheduled_content if it has a future `scheduled_for`.
552async fn approve_single_item(
553    state: &AppState,
554    account_id: &str,
555    item: &approval_queue::ApprovalItem,
556    review: &approval_queue::ReviewAction,
557) -> Result<(), ApiError> {
558    let schedule_bridge = item.scheduled_for.as_deref().and_then(|sched| {
559        chrono::NaiveDateTime::parse_from_str(sched, "%Y-%m-%dT%H:%M:%SZ")
560            .ok()
561            .filter(|dt| *dt > chrono::Utc::now().naive_utc())
562            .map(|_| sched.to_string())
563    });
564
565    if let Some(ref sched) = schedule_bridge {
566        approval_queue::update_status_with_review_for(
567            &state.db,
568            account_id,
569            item.id,
570            "scheduled",
571            review,
572        )
573        .await?;
574
575        scheduled_content::insert_for(
576            &state.db,
577            account_id,
578            &item.action_type,
579            &item.generated_content,
580            Some(sched),
581        )
582        .await?;
583    } else {
584        approval_queue::update_status_with_review_for(
585            &state.db, account_id, item.id, "approved", review,
586        )
587        .await?;
588    }
589
590    Ok(())
591}
592
593/// Read the config from disk (best-effort, returns defaults on failure).
594fn read_config(state: &AppState) -> Config {
595    std::fs::read_to_string(&state.config_path)
596        .ok()
597        .and_then(|s| toml::from_str(&s).ok())
598        .unwrap_or_default()
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn escape_csv_no_special_chars() {
607        assert_eq!(escape_csv("hello"), "hello");
608        assert_eq!(escape_csv("simple text"), "simple text");
609    }
610
611    #[test]
612    fn escape_csv_with_comma() {
613        assert_eq!(escape_csv("hello, world"), "\"hello, world\"");
614    }
615
616    #[test]
617    fn escape_csv_with_quotes() {
618        assert_eq!(escape_csv(r#"say "hi""#), r#""say ""hi""""#);
619    }
620
621    #[test]
622    fn escape_csv_with_newline() {
623        assert_eq!(escape_csv("line1\nline2"), "\"line1\nline2\"");
624    }
625
626    #[test]
627    fn escape_csv_empty() {
628        assert_eq!(escape_csv(""), "");
629    }
630
631    #[test]
632    fn escape_csv_with_all_special() {
633        let result = escape_csv("a,b\"c\nd");
634        assert!(result.starts_with('"'));
635        assert!(result.ends_with('"'));
636    }
637
638    #[test]
639    fn default_status_is_pending() {
640        assert_eq!(default_status(), "pending");
641    }
642
643    #[test]
644    fn default_editor_is_dashboard() {
645        assert_eq!(default_editor(), "dashboard");
646    }
647
648    #[test]
649    fn default_csv_is_csv() {
650        assert_eq!(default_csv(), "csv");
651    }
652
653    #[test]
654    fn default_export_status_includes_all() {
655        let status = default_export_status();
656        assert!(status.contains("pending"));
657        assert!(status.contains("approved"));
658        assert!(status.contains("rejected"));
659        assert!(status.contains("posted"));
660    }
661
662    #[test]
663    fn approval_query_deserialize_defaults() {
664        let json = r#"{}"#;
665        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
666        assert_eq!(query.status, "pending");
667        assert!(query.action_type.is_none());
668        assert!(query.reviewed_by.is_none());
669        assert!(query.since.is_none());
670    }
671
672    #[test]
673    fn approval_query_deserialize_with_type() {
674        let json = r#"{"type": "reply"}"#;
675        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
676        assert_eq!(query.action_type.as_deref(), Some("reply"));
677    }
678
679    #[test]
680    fn edit_content_request_deserialize() {
681        let json = r#"{"content": "new text"}"#;
682        let req: EditContentRequest = serde_json::from_str(json).unwrap();
683        assert_eq!(req.content, "new text");
684        assert!(req.media_paths.is_none());
685        assert_eq!(req.editor, "dashboard");
686    }
687
688    #[test]
689    fn edit_content_request_with_media() {
690        let json = r#"{"content": "text", "media_paths": ["a.png"], "editor": "cli"}"#;
691        let req: EditContentRequest = serde_json::from_str(json).unwrap();
692        assert_eq!(req.media_paths.as_ref().unwrap().len(), 1);
693        assert_eq!(req.editor, "cli");
694    }
695
696    #[test]
697    fn batch_approve_request_deserialize_defaults() {
698        let json = r#"{}"#;
699        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
700        assert!(req.max.is_none());
701        assert!(req.ids.is_none());
702        assert!(req.review.actor.is_none());
703    }
704
705    #[test]
706    fn batch_approve_request_with_ids() {
707        let json = r#"{"ids": [1, 2, 3], "review": {"actor": "admin"}}"#;
708        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
709        assert_eq!(req.ids.as_ref().unwrap().len(), 3);
710        assert_eq!(req.review.actor.as_deref(), Some("admin"));
711    }
712
713    #[test]
714    fn export_query_deserialize_defaults() {
715        let json = r#"{}"#;
716        let query: ExportQuery = serde_json::from_str(json).unwrap();
717        assert_eq!(query.format, "csv");
718        assert!(query.status.contains("pending"));
719        assert!(query.action_type.is_none());
720    }
721
722    #[test]
723    fn export_query_json_format() {
724        let json = r#"{"format": "json", "type": "tweet"}"#;
725        let query: ExportQuery = serde_json::from_str(json).unwrap();
726        assert_eq!(query.format, "json");
727        assert_eq!(query.action_type.as_deref(), Some("tweet"));
728    }
729
730    // -----------------------------------------------------------------------
731    // Extended approval helper tests for coverage push
732    // -----------------------------------------------------------------------
733
734    #[test]
735    fn escape_csv_tab_character() {
736        // Tab is not a special CSV char in our impl
737        assert_eq!(escape_csv("hello\tworld"), "hello\tworld");
738    }
739
740    #[test]
741    fn escape_csv_only_comma() {
742        let result = escape_csv(",");
743        assert_eq!(result, r#"",""#);
744    }
745
746    #[test]
747    fn escape_csv_only_quote() {
748        let result = escape_csv(r#"""#);
749        assert_eq!(result, r#""""""#);
750    }
751
752    #[test]
753    fn escape_csv_only_newline() {
754        let result = escape_csv("\n");
755        assert_eq!(result, "\"\n\"");
756    }
757
758    #[test]
759    fn escape_csv_mixed_special_chars() {
760        let result = escape_csv("a,b\nc\"d");
761        assert!(result.starts_with('"'));
762        assert!(result.ends_with('"'));
763        assert!(result.contains("\"\""));
764    }
765
766    #[test]
767    fn escape_csv_long_text() {
768        let text = "a".repeat(1000);
769        let result = escape_csv(&text);
770        assert_eq!(result, text); // no special chars
771    }
772
773    #[test]
774    fn escape_csv_unicode() {
775        assert_eq!(escape_csv("caf\u{00E9}"), "caf\u{00E9}");
776        assert_eq!(escape_csv("\u{1F600}"), "\u{1F600}");
777    }
778
779    #[test]
780    fn approval_query_deserialize_with_all_fields() {
781        let json = r#"{
782            "status": "approved,rejected",
783            "type": "tweet",
784            "reviewed_by": "admin",
785            "since": "2026-01-01T00:00:00Z"
786        }"#;
787        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
788        assert_eq!(query.status, "approved,rejected");
789        assert_eq!(query.action_type.as_deref(), Some("tweet"));
790        assert_eq!(query.reviewed_by.as_deref(), Some("admin"));
791        assert_eq!(query.since.as_deref(), Some("2026-01-01T00:00:00Z"));
792    }
793
794    #[test]
795    fn approval_query_status_split() {
796        let json = r#"{"status": "pending,approved,rejected"}"#;
797        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
798        let statuses: Vec<&str> = query.status.split(',').map(|s| s.trim()).collect();
799        assert_eq!(statuses.len(), 3);
800        assert_eq!(statuses[0], "pending");
801        assert_eq!(statuses[1], "approved");
802        assert_eq!(statuses[2], "rejected");
803    }
804
805    #[test]
806    fn edit_content_request_empty_media_paths() {
807        let json = r#"{"content": "text", "media_paths": []}"#;
808        let req: EditContentRequest = serde_json::from_str(json).unwrap();
809        assert!(req.media_paths.as_ref().unwrap().is_empty());
810    }
811
812    #[test]
813    fn edit_content_request_multiple_media() {
814        let json = r#"{"content": "text", "media_paths": ["a.png", "b.jpg", "c.gif"]}"#;
815        let req: EditContentRequest = serde_json::from_str(json).unwrap();
816        assert_eq!(req.media_paths.as_ref().unwrap().len(), 3);
817    }
818
819    #[test]
820    fn batch_approve_request_with_max() {
821        let json = r#"{"max": 10}"#;
822        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
823        assert_eq!(req.max, Some(10));
824        assert!(req.ids.is_none());
825    }
826
827    #[test]
828    fn batch_approve_request_with_review_notes() {
829        let json = r#"{"review": {"actor": "admin", "notes": "LGTM"}}"#;
830        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
831        assert_eq!(req.review.actor.as_deref(), Some("admin"));
832        assert_eq!(req.review.notes.as_deref(), Some("LGTM"));
833    }
834
835    #[test]
836    fn batch_approve_request_empty_ids() {
837        let json = r#"{"ids": []}"#;
838        let req: BatchApproveRequest = serde_json::from_str(json).unwrap();
839        assert!(req.ids.as_ref().unwrap().is_empty());
840    }
841
842    #[test]
843    fn export_query_custom_status() {
844        let json = r#"{"status": "posted"}"#;
845        let query: ExportQuery = serde_json::from_str(json).unwrap();
846        assert_eq!(query.status, "posted");
847        assert_eq!(query.format, "csv"); // default
848    }
849
850    #[test]
851    fn export_query_with_type_filter() {
852        let json = r#"{"type": "thread_tweet"}"#;
853        let query: ExportQuery = serde_json::from_str(json).unwrap();
854        assert_eq!(query.action_type.as_deref(), Some("thread_tweet"));
855    }
856
857    #[test]
858    fn default_status_value_check() {
859        let s = default_status();
860        assert_eq!(s, "pending");
861        assert!(!s.is_empty());
862    }
863
864    #[test]
865    fn default_editor_value_check() {
866        let e = default_editor();
867        assert_eq!(e, "dashboard");
868        assert!(!e.is_empty());
869    }
870
871    #[test]
872    fn default_csv_value_check() {
873        let c = default_csv();
874        assert_eq!(c, "csv");
875        assert!(!c.is_empty());
876    }
877
878    #[test]
879    fn default_export_status_contains_all_four() {
880        let s = default_export_status();
881        let parts: Vec<&str> = s.split(',').collect();
882        assert_eq!(parts.len(), 4);
883        assert!(parts.contains(&"pending"));
884        assert!(parts.contains(&"approved"));
885        assert!(parts.contains(&"rejected"));
886        assert!(parts.contains(&"posted"));
887    }
888
889    #[test]
890    fn escape_csv_preserves_spaces() {
891        assert_eq!(escape_csv("hello world"), "hello world");
892        assert_eq!(escape_csv("  leading"), "  leading");
893    }
894
895    #[test]
896    fn escape_csv_carriage_return() {
897        // \r alone is not a trigger in our impl
898        assert_eq!(escape_csv("hello\rworld"), "hello\rworld");
899    }
900
901    #[test]
902    fn escape_csv_double_quotes_escaped() {
903        let result = escape_csv(r#"say "hello" to "world""#);
904        assert!(result.starts_with('"'));
905        assert!(result.ends_with('"'));
906        // Each " becomes ""
907        assert!(result.contains(r#""""#));
908    }
909
910    #[test]
911    fn approval_query_type_reply() {
912        let json = r#"{"type": "reply"}"#;
913        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
914        assert_eq!(query.action_type, Some("reply".to_string()));
915    }
916
917    #[test]
918    fn approval_query_type_thread_tweet() {
919        let json = r#"{"type": "thread_tweet"}"#;
920        let query: ApprovalQuery = serde_json::from_str(json).unwrap();
921        assert_eq!(query.action_type, Some("thread_tweet".to_string()));
922    }
923
924    #[test]
925    fn edit_content_request_custom_editor() {
926        let json = r#"{"content": "test", "editor": "api"}"#;
927        let req: EditContentRequest = serde_json::from_str(json).unwrap();
928        assert_eq!(req.editor, "api");
929    }
930
931    #[test]
932    fn batch_approve_request_large_ids_list() {
933        let ids: Vec<i64> = (1..=100).collect();
934        let json = serde_json::json!({"ids": ids});
935        let req: BatchApproveRequest = serde_json::from_str(&json.to_string()).unwrap();
936        assert_eq!(req.ids.as_ref().unwrap().len(), 100);
937    }
938}