1use 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#[derive(Deserialize)]
19pub struct ApprovalQuery {
20 #[serde(default = "default_status")]
22 pub status: String,
23 #[serde(rename = "type")]
25 pub action_type: Option<String>,
26 pub reviewed_by: Option<String>,
28 pub since: Option<String>,
30}
31
32fn default_status() -> String {
33 "pending".to_string()
34}
35
36pub 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
59pub 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#[derive(Deserialize)]
70pub struct EditContentRequest {
71 pub content: String,
72 #[serde(default)]
74 pub media_paths: Option<Vec<String>>,
75 #[serde(default = "default_editor")]
77 pub editor: String,
78}
79
80fn default_editor() -> String {
81 "dashboard".to_string()
82}
83
84pub 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 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 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 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
157pub 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 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 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 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 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 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 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
286pub 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 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#[derive(Deserialize)]
340pub struct BatchApproveRequest {
341 #[serde(default)]
343 pub max: Option<usize>,
344 #[serde(default)]
346 pub ids: Option<Vec<i64>>,
347 #[serde(default)]
349 pub review: approval_queue::ReviewAction,
350}
351
352pub 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 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 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 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 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#[derive(Deserialize)]
447pub struct ExportQuery {
448 #[serde(default = "default_csv")]
450 pub format: String,
451 #[serde(default = "default_export_status")]
453 pub status: String,
454 #[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
467pub 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
531fn 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
540pub 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 let history = approval_queue::get_edit_history(&state.db, id).await?;
548 Ok(Json(json!(history)))
549}
550
551async 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
593fn 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 #[test]
735 fn escape_csv_tab_character() {
736 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); }
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"); }
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 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 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}