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 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 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 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 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 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 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 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
294pub 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 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 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#[derive(Deserialize)]
356pub struct BatchApproveRequest {
357 #[serde(default)]
359 pub max: Option<usize>,
360 #[serde(default)]
362 pub ids: Option<Vec<i64>>,
363 #[serde(default)]
365 pub review: approval_queue::ReviewAction,
366}
367
368pub 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 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 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 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 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#[derive(Deserialize)]
463pub struct ExportQuery {
464 #[serde(default = "default_csv")]
466 pub format: String,
467 #[serde(default = "default_export_status")]
469 pub status: String,
470 #[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
483pub 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
547fn 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
556pub 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 let history = approval_queue::get_edit_history(&state.db, id).await?;
564 Ok(Json(json!(history)))
565}
566
567async 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
609fn 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 #[test]
751 fn escape_csv_tab_character() {
752 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); }
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"); }
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 #[test]
911 fn status_guard_approve_rejects_non_pending() {
912 let item_status = "approved";
914 let is_valid_for_approval = item_status == "pending";
915 assert!(!is_valid_for_approval);
916 }
917
918 #[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 #[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 #[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 #[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 #[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 #[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 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 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}