mockforge_ui/handlers/
promotions.rs

1//! Promotion workflow API handlers
2//!
3//! Provides REST endpoints for managing promotions between environments.
4
5use axum::{
6    extract::{Path, Query, State},
7    http::{HeaderMap, StatusCode},
8    response::{Json, Response},
9};
10use mockforge_collab::promotion::PromotionService;
11use mockforge_core::workspace::{
12    mock_environment::MockEnvironmentName,
13    scenario_promotion::{
14        ApprovalRules, PromotionEntityType, PromotionRequest, PromotionStatus,
15        ScenarioPromotionWorkflow,
16    },
17};
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20use uuid::Uuid;
21
22use crate::handlers::workspaces::{ApiResponse, WorkspaceState};
23use crate::rbac::extract_user_context;
24
25/// Promotion state
26#[derive(Clone)]
27pub struct PromotionState {
28    /// Promotion service
29    pub promotion_service: Arc<PromotionService>,
30    /// Workspace state for accessing workspace configs
31    pub workspace_state: WorkspaceState,
32}
33
34impl PromotionState {
35    /// Create a new promotion state
36    pub fn new(promotion_service: Arc<PromotionService>, workspace_state: WorkspaceState) -> Self {
37        Self {
38            promotion_service,
39            workspace_state,
40        }
41    }
42}
43
44/// Create promotion request
45#[derive(Debug, Clone, Deserialize)]
46pub struct CreatePromotionRequest {
47    /// Entity type (scenario, persona, config)
48    pub entity_type: String,
49    /// Entity ID
50    pub entity_id: String,
51    /// Entity version (optional)
52    pub entity_version: Option<String>,
53    /// Workspace ID
54    pub workspace_id: String,
55    /// Source environment
56    pub from_environment: String,
57    /// Target environment
58    pub to_environment: String,
59    /// Whether approval is required (optional, will be determined automatically if not provided)
60    pub requires_approval: Option<bool>,
61    /// Scenario tags (for scenarios only, used to determine approval requirements)
62    #[serde(default)]
63    pub scenario_tags: Option<Vec<String>>,
64    /// Comments
65    pub comments: Option<String>,
66    /// Metadata (optional)
67    pub metadata: Option<serde_json::Value>,
68}
69
70/// Promotion response
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PromotionResponse {
73    /// Promotion ID
74    pub promotion_id: String,
75    /// Entity type
76    pub entity_type: String,
77    /// Entity ID
78    pub entity_id: String,
79    /// Entity version
80    pub entity_version: Option<String>,
81    /// From environment
82    pub from_environment: String,
83    /// To environment
84    pub to_environment: String,
85    /// Status
86    pub status: String,
87    /// Promoted by user ID
88    pub promoted_by: String,
89    /// Approved by user ID (if approved)
90    pub approved_by: Option<String>,
91    /// Comments
92    pub comments: Option<String>,
93    /// PR URL (if GitOps enabled)
94    pub pr_url: Option<String>,
95    /// Timestamp
96    pub timestamp: String,
97}
98
99/// Update promotion status request
100#[derive(Debug, Clone, Deserialize)]
101pub struct UpdatePromotionStatusRequest {
102    /// New status
103    pub status: String,
104    /// Approver user ID (if approving)
105    pub approved_by: Option<String>,
106}
107
108/// List promotions query parameters
109#[derive(Debug, Clone, Deserialize)]
110pub struct ListPromotionsQuery {
111    /// Limit results
112    #[serde(default = "default_limit")]
113    pub limit: i64,
114    /// Filter by status
115    pub status: Option<String>,
116    /// Filter by entity type
117    pub entity_type: Option<String>,
118}
119
120fn default_limit() -> i64 {
121    100
122}
123
124/// Create a promotion request
125///
126/// POST /api/v2/promotions
127pub async fn create_promotion(
128    State(state): State<PromotionState>,
129    headers: HeaderMap,
130    Json(body): Json<CreatePromotionRequest>,
131) -> Result<Json<ApiResponse<PromotionResponse>>, Response> {
132    // Extract user context from headers (set by RBAC middleware)
133    let user_context = extract_user_context(&headers).ok_or_else(|| {
134        Response::builder()
135            .status(StatusCode::UNAUTHORIZED)
136            .body("User authentication required".into())
137            .unwrap()
138    })?;
139
140    let user_id = Uuid::parse_str(&user_context.user_id).map_err(|_| {
141        Response::builder()
142            .status(StatusCode::BAD_REQUEST)
143            .body("Invalid user ID".into())
144            .unwrap()
145    })?;
146    // Parse entity type
147    let entity_type = match body.entity_type.to_lowercase().as_str() {
148        "scenario" => PromotionEntityType::Scenario,
149        "persona" => PromotionEntityType::Persona,
150        "config" => PromotionEntityType::Config,
151        _ => {
152            return Ok(Json(ApiResponse::error(format!(
153                "Invalid entity type: {}",
154                body.entity_type
155            ))));
156        }
157    };
158
159    // Parse environments
160    let from_env = match MockEnvironmentName::from_str(&body.from_environment) {
161        Some(env) => env,
162        None => {
163            return Ok(Json(ApiResponse::error(format!(
164                "Invalid from_environment: {}",
165                body.from_environment
166            ))));
167        }
168    };
169
170    let to_env = match MockEnvironmentName::from_str(&body.to_environment) {
171        Some(env) => env,
172        None => {
173            return Ok(Json(ApiResponse::error(format!(
174                "Invalid to_environment: {}",
175                body.to_environment
176            ))));
177        }
178    };
179
180    // Determine if approval is required
181    // If not explicitly set, check using approval workflow (for scenarios with tags)
182    let (requires_approval, approval_reason) = if let Some(explicit_approval) =
183        body.requires_approval
184    {
185        (
186            explicit_approval,
187            if explicit_approval {
188                Some("Manual approval required for promotion".to_string())
189            } else {
190                None
191            },
192        )
193    } else if entity_type == PromotionEntityType::Scenario {
194        // For scenarios, check approval workflow if tags are provided
195        let scenario_tags = body.scenario_tags.as_deref().unwrap_or(&[]);
196        let approval_rules = ApprovalRules::default();
197        let (requires, reason) =
198            ScenarioPromotionWorkflow::requires_approval(scenario_tags, to_env, &approval_rules);
199        (requires, reason)
200    } else {
201        // Default: require approval for safety
202        (true, Some("Approval required for promotion".to_string()))
203    };
204
205    // Build metadata including scenario tags if provided
206    let mut metadata: std::collections::HashMap<String, serde_json::Value> = body
207        .metadata
208        .as_ref()
209        .and_then(|v| {
210            if let serde_json::Value::Object(map) = v {
211                Some(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
212            } else {
213                None
214            }
215        })
216        .unwrap_or_default();
217
218    // Store scenario tags in metadata for preservation
219    if let Some(tags) = &body.scenario_tags {
220        metadata.insert(
221            "scenario_tags".to_string(),
222            serde_json::to_value(tags).unwrap_or(serde_json::Value::Array(vec![])),
223        );
224    }
225
226    // Create promotion request
227    let promotion_request = PromotionRequest {
228        entity_type,
229        entity_id: body.entity_id.clone(),
230        entity_version: body.entity_version.clone(),
231        workspace_id: body.workspace_id.clone(),
232        from_environment: from_env,
233        to_environment: to_env,
234        requires_approval,
235        approval_required_reason: approval_reason,
236        comments: body.comments.clone(),
237        metadata,
238    };
239
240    // Get workspace config for GitOps (if needed)
241    let workspace_config = {
242        let registry = state.workspace_state.registry.read().await;
243        if let Ok(workspace) = registry.get_workspace(&body.workspace_id) {
244            // Serialize workspace config to JSON
245            serde_json::to_value(&workspace.workspace.config).ok()
246        } else {
247            None
248        }
249    };
250
251    // Record promotion
252    let promotion_id = match state
253        .promotion_service
254        .record_promotion(&promotion_request, user_id, PromotionStatus::Pending, workspace_config)
255        .await
256    {
257        Ok(id) => id,
258        Err(e) => {
259            return Ok(Json(ApiResponse::error(format!("Failed to create promotion: {}", e))));
260        }
261    };
262
263    // Build response
264    let response = PromotionResponse {
265        promotion_id: promotion_id.to_string(),
266        entity_type: body.entity_type,
267        entity_id: body.entity_id,
268        entity_version: body.entity_version,
269        from_environment: body.from_environment,
270        to_environment: body.to_environment,
271        status: "pending".to_string(),
272        promoted_by: user_id.to_string(),
273        approved_by: None,
274        comments: body.comments,
275        pr_url: None, // Will be populated if GitOps creates PR
276        timestamp: chrono::Utc::now().to_rfc3339(),
277    };
278
279    Ok(Json(ApiResponse::success(response)))
280}
281
282/// Get promotion details
283///
284/// GET /api/v2/promotions/{promotion_id}
285pub async fn get_promotion(
286    State(state): State<PromotionState>,
287    Path(promotion_id): Path<String>,
288) -> Result<Json<ApiResponse<PromotionResponse>>, Response> {
289    let promotion_uuid = match Uuid::parse_str(&promotion_id) {
290        Ok(uuid) => uuid,
291        Err(_) => {
292            return Ok(Json(ApiResponse::error("Invalid promotion ID".to_string())));
293        }
294    };
295
296    // Get promotion by ID
297    match state.promotion_service.get_promotion_by_id(promotion_uuid).await {
298        Ok(Some(promotion)) => {
299            let response = PromotionResponse {
300                promotion_id: promotion.promotion_id,
301                entity_type: promotion.entity_type.to_string(),
302                entity_id: promotion.entity_id,
303                entity_version: promotion.entity_version,
304                from_environment: promotion.from_environment.as_str().to_string(),
305                to_environment: promotion.to_environment.as_str().to_string(),
306                status: promotion.status.to_string(),
307                promoted_by: promotion.promoted_by,
308                approved_by: promotion.approved_by,
309                comments: promotion.comments,
310                pr_url: promotion.pr_url,
311                timestamp: promotion.timestamp.to_rfc3339(),
312            };
313            Ok(Json(ApiResponse::success(response)))
314        }
315        Ok(None) => Ok(Json(ApiResponse::error("Promotion not found".to_string()))),
316        Err(e) => Ok(Json(ApiResponse::error(format!("Failed to get promotion: {}", e)))),
317    }
318}
319
320/// Update promotion status
321///
322/// PUT /api/v2/promotions/{promotion_id}/status
323pub async fn update_promotion_status(
324    State(state): State<PromotionState>,
325    headers: HeaderMap,
326    Path(promotion_id): Path<String>,
327    Json(body): Json<UpdatePromotionStatusRequest>,
328) -> Result<Json<ApiResponse<PromotionResponse>>, Response> {
329    // Extract user context from headers (set by RBAC middleware)
330    let user_context = extract_user_context(&headers).ok_or_else(|| {
331        Response::builder()
332            .status(StatusCode::UNAUTHORIZED)
333            .body("User authentication required".into())
334            .unwrap()
335    })?;
336    let promotion_uuid = match Uuid::parse_str(&promotion_id) {
337        Ok(uuid) => uuid,
338        Err(_) => {
339            return Ok(Json(ApiResponse::error("Invalid promotion ID".to_string())));
340        }
341    };
342
343    // Parse status
344    let status = match body.status.to_lowercase().as_str() {
345        "pending" => PromotionStatus::Pending,
346        "approved" => PromotionStatus::Approved,
347        "rejected" => PromotionStatus::Rejected,
348        "completed" => PromotionStatus::Completed,
349        "failed" => PromotionStatus::Failed,
350        _ => {
351            return Ok(Json(ApiResponse::error(format!("Invalid status: {}", body.status))));
352        }
353    };
354
355    // Use authenticated user as approver if approving/rejecting
356    let approver_id = if matches!(status, PromotionStatus::Approved | PromotionStatus::Rejected) {
357        Some(Uuid::parse_str(&user_context.user_id).map_err(|_| {
358            Response::builder()
359                .status(StatusCode::BAD_REQUEST)
360                .body("Invalid user ID".into())
361                .unwrap()
362        })?)
363    } else {
364        body.approved_by.and_then(|s| Uuid::parse_str(&s).ok())
365    };
366
367    // Update status
368    match state
369        .promotion_service
370        .update_promotion_status(promotion_uuid, status, approver_id)
371        .await
372    {
373        Ok(_) => {
374            // Get updated promotion
375            match state.promotion_service.get_promotion_by_id(promotion_uuid).await {
376                Ok(Some(promotion)) => {
377                    let response = PromotionResponse {
378                        promotion_id: promotion.promotion_id,
379                        entity_type: promotion.entity_type.to_string(),
380                        entity_id: promotion.entity_id,
381                        entity_version: promotion.entity_version,
382                        from_environment: promotion.from_environment.as_str().to_string(),
383                        to_environment: promotion.to_environment.as_str().to_string(),
384                        status: promotion.status.to_string(),
385                        promoted_by: promotion.promoted_by,
386                        approved_by: promotion.approved_by,
387                        comments: promotion.comments,
388                        pr_url: promotion.pr_url,
389                        timestamp: promotion.timestamp.to_rfc3339(),
390                    };
391                    Ok(Json(ApiResponse::success(response)))
392                }
393                Ok(None) => {
394                    Ok(Json(ApiResponse::error("Promotion not found after update".to_string())))
395                }
396                Err(e) => {
397                    Ok(Json(ApiResponse::error(format!("Failed to get updated promotion: {}", e))))
398                }
399            }
400        }
401        Err(e) => Ok(Json(ApiResponse::error(format!("Failed to update promotion status: {}", e)))),
402    }
403}
404
405/// List promotions for a workspace
406///
407/// GET /api/v2/promotions/workspace/{workspace_id}
408pub async fn list_workspace_promotions(
409    State(state): State<PromotionState>,
410    Path(workspace_id): Path<String>,
411    Query(query): Query<ListPromotionsQuery>,
412) -> Result<Json<ApiResponse<Vec<PromotionResponse>>>, Response> {
413    match state
414        .promotion_service
415        .get_workspace_promotions(&workspace_id, Some(query.limit))
416        .await
417    {
418        Ok(promotions) => {
419            let responses: Vec<PromotionResponse> = promotions
420                .into_iter()
421                .filter(|p| {
422                    // Filter by status if provided
423                    if let Some(ref status_filter) = query.status {
424                        p.status.to_string() == *status_filter
425                    } else {
426                        true
427                    }
428                })
429                .filter(|p| {
430                    // Filter by entity type if provided
431                    if let Some(ref entity_type_filter) = query.entity_type {
432                        p.entity_type.to_string() == *entity_type_filter
433                    } else {
434                        true
435                    }
436                })
437                .map(|p| PromotionResponse {
438                    promotion_id: p.promotion_id,
439                    entity_type: p.entity_type.to_string(),
440                    entity_id: p.entity_id,
441                    entity_version: p.entity_version,
442                    from_environment: p.from_environment.as_str().to_string(),
443                    to_environment: p.to_environment.as_str().to_string(),
444                    status: p.status.to_string(),
445                    promoted_by: p.promoted_by,
446                    approved_by: p.approved_by,
447                    comments: p.comments,
448                    pr_url: p.pr_url,
449                    timestamp: p.timestamp.to_rfc3339(),
450                })
451                .collect();
452
453            Ok(Json(ApiResponse::success(responses)))
454        }
455        Err(e) => Ok(Json(ApiResponse::error(format!("Failed to list promotions: {}", e)))),
456    }
457}
458
459/// List pending promotions
460///
461/// GET /api/v2/promotions/pending
462pub async fn list_pending_promotions(
463    State(state): State<PromotionState>,
464    Query(query): Query<ListPromotionsQuery>,
465) -> Result<Json<ApiResponse<Vec<PromotionResponse>>>, Response> {
466    match state.promotion_service.get_pending_promotions(None).await {
467        Ok(promotions) => {
468            let responses: Vec<PromotionResponse> = promotions
469                .into_iter()
470                .take(query.limit as usize)
471                .map(|p| PromotionResponse {
472                    promotion_id: p.promotion_id,
473                    entity_type: p.entity_type.to_string(),
474                    entity_id: p.entity_id,
475                    entity_version: p.entity_version,
476                    from_environment: p.from_environment.as_str().to_string(),
477                    to_environment: p.to_environment.as_str().to_string(),
478                    status: p.status.to_string(),
479                    promoted_by: p.promoted_by,
480                    approved_by: p.approved_by,
481                    comments: p.comments,
482                    pr_url: p.pr_url,
483                    timestamp: p.timestamp.to_rfc3339(),
484                })
485                .collect();
486
487            Ok(Json(ApiResponse::success(responses)))
488        }
489        Err(e) => Ok(Json(ApiResponse::error(format!("Failed to list pending promotions: {}", e)))),
490    }
491}
492
493/// Get promotion history query parameters
494#[derive(Debug, Clone, Deserialize)]
495pub struct PromotionHistoryQuery {
496    /// Workspace ID
497    pub workspace_id: String,
498}
499
500/// Get promotion history for an entity
501///
502/// GET /api/v2/promotions/entity/{entity_type}/{entity_id}
503pub async fn get_entity_promotion_history(
504    State(state): State<PromotionState>,
505    Path((entity_type, entity_id)): Path<(String, String)>,
506    Query(query): Query<PromotionHistoryQuery>,
507) -> Result<Json<ApiResponse<serde_json::Value>>, Response> {
508    // Parse entity type
509    let entity_type_enum = match entity_type.to_lowercase().as_str() {
510        "scenario" => PromotionEntityType::Scenario,
511        "persona" => PromotionEntityType::Persona,
512        "config" => PromotionEntityType::Config,
513        _ => {
514            return Ok(Json(ApiResponse::error(format!("Invalid entity type: {}", entity_type))));
515        }
516    };
517
518    match state
519        .promotion_service
520        .get_promotion_history(&query.workspace_id, entity_type_enum, &entity_id)
521        .await
522    {
523        Ok(history) => {
524            let history_json = serde_json::to_value(history).unwrap_or(serde_json::json!({}));
525            Ok(Json(ApiResponse::success(history_json)))
526        }
527        Err(e) => Ok(Json(ApiResponse::error(format!("Failed to get promotion history: {}", e)))),
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_create_promotion_request_deserialization() {
537        let json = r#"{
538            "entity_type": "scenario",
539            "entity_id": "test-scenario-123",
540            "entity_version": "v1.0",
541            "workspace_id": "workspace-1",
542            "from_environment": "dev",
543            "to_environment": "prod",
544            "requires_approval": true,
545            "comments": "Promoting to prod"
546        }"#;
547
548        let request: CreatePromotionRequest = serde_json::from_str(json).unwrap();
549        assert_eq!(request.entity_type, "scenario");
550        assert_eq!(request.entity_id, "test-scenario-123");
551        assert_eq!(request.entity_version, Some("v1.0".to_string()));
552        assert_eq!(request.workspace_id, "workspace-1");
553        assert_eq!(request.from_environment, "dev");
554        assert_eq!(request.to_environment, "prod");
555        assert_eq!(request.requires_approval, Some(true));
556        assert_eq!(request.comments, Some("Promoting to prod".to_string()));
557    }
558
559    #[test]
560    fn test_create_promotion_request_with_tags() {
561        let json = r#"{
562            "entity_type": "scenario",
563            "entity_id": "test-123",
564            "workspace_id": "workspace-1",
565            "from_environment": "dev",
566            "to_environment": "staging",
567            "scenario_tags": ["critical", "payment"]
568        }"#;
569
570        let request: CreatePromotionRequest = serde_json::from_str(json).unwrap();
571        assert!(request.scenario_tags.is_some());
572        let tags = request.scenario_tags.unwrap();
573        assert_eq!(tags.len(), 2);
574        assert!(tags.contains(&"critical".to_string()));
575        assert!(tags.contains(&"payment".to_string()));
576    }
577
578    #[test]
579    fn test_create_promotion_request_without_optional_fields() {
580        let json = r#"{
581            "entity_type": "persona",
582            "entity_id": "persona-456",
583            "workspace_id": "workspace-2",
584            "from_environment": "staging",
585            "to_environment": "prod"
586        }"#;
587
588        let request: CreatePromotionRequest = serde_json::from_str(json).unwrap();
589        assert_eq!(request.entity_version, None);
590        assert_eq!(request.requires_approval, None);
591        assert_eq!(request.scenario_tags, None);
592        assert_eq!(request.comments, None);
593        assert_eq!(request.metadata, None);
594    }
595
596    #[test]
597    fn test_create_promotion_request_with_metadata() {
598        let json = r#"{
599            "entity_type": "config",
600            "entity_id": "config-789",
601            "workspace_id": "workspace-3",
602            "from_environment": "dev",
603            "to_environment": "staging",
604            "metadata": {"key": "value", "number": 123}
605        }"#;
606
607        let request: CreatePromotionRequest = serde_json::from_str(json).unwrap();
608        assert!(request.metadata.is_some());
609        let metadata = request.metadata.unwrap();
610        assert_eq!(metadata.get("key").unwrap().as_str().unwrap(), "value");
611        assert_eq!(metadata.get("number").unwrap().as_i64().unwrap(), 123);
612    }
613
614    #[test]
615    fn test_promotion_response_serialization() {
616        let response = PromotionResponse {
617            promotion_id: "promo-123".to_string(),
618            entity_type: "scenario".to_string(),
619            entity_id: "scenario-456".to_string(),
620            entity_version: Some("v2.0".to_string()),
621            from_environment: "dev".to_string(),
622            to_environment: "prod".to_string(),
623            status: "pending".to_string(),
624            promoted_by: "user-789".to_string(),
625            approved_by: Some("admin-001".to_string()),
626            comments: Some("Test promotion".to_string()),
627            pr_url: Some("https://github.com/org/repo/pull/123".to_string()),
628            timestamp: "2024-01-01T00:00:00Z".to_string(),
629        };
630
631        let serialized = serde_json::to_string(&response).unwrap();
632        assert!(serialized.contains("promo-123"));
633        assert!(serialized.contains("scenario"));
634        assert!(serialized.contains("pending"));
635        assert!(serialized.contains("admin-001"));
636    }
637
638    #[test]
639    fn test_promotion_response_without_optional_fields() {
640        let response = PromotionResponse {
641            promotion_id: "promo-999".to_string(),
642            entity_type: "persona".to_string(),
643            entity_id: "persona-111".to_string(),
644            entity_version: None,
645            from_environment: "staging".to_string(),
646            to_environment: "prod".to_string(),
647            status: "approved".to_string(),
648            promoted_by: "user-222".to_string(),
649            approved_by: None,
650            comments: None,
651            pr_url: None,
652            timestamp: "2024-01-02T00:00:00Z".to_string(),
653        };
654
655        let serialized = serde_json::to_string(&response).unwrap();
656        let deserialized: PromotionResponse = serde_json::from_str(&serialized).unwrap();
657
658        assert_eq!(deserialized.promotion_id, "promo-999");
659        assert_eq!(deserialized.entity_version, None);
660        assert_eq!(deserialized.approved_by, None);
661        assert_eq!(deserialized.comments, None);
662        assert_eq!(deserialized.pr_url, None);
663    }
664
665    #[test]
666    fn test_update_promotion_status_request_deserialization() {
667        let json = r#"{
668            "status": "approved",
669            "approved_by": "admin-123"
670        }"#;
671
672        let request: UpdatePromotionStatusRequest = serde_json::from_str(json).unwrap();
673        assert_eq!(request.status, "approved");
674        assert_eq!(request.approved_by, Some("admin-123".to_string()));
675    }
676
677    #[test]
678    fn test_update_promotion_status_request_without_approver() {
679        let json = r#"{"status": "rejected"}"#;
680
681        let request: UpdatePromotionStatusRequest = serde_json::from_str(json).unwrap();
682        assert_eq!(request.status, "rejected");
683        assert_eq!(request.approved_by, None);
684    }
685
686    #[test]
687    fn test_list_promotions_query_default() {
688        let json = "{}";
689        let query: ListPromotionsQuery = serde_json::from_str(json).unwrap();
690        assert_eq!(query.limit, 100); // default value
691        assert_eq!(query.status, None);
692        assert_eq!(query.entity_type, None);
693    }
694
695    #[test]
696    fn test_list_promotions_query_with_filters() {
697        let json = r#"{
698            "limit": 50,
699            "status": "pending",
700            "entity_type": "scenario"
701        }"#;
702
703        let query: ListPromotionsQuery = serde_json::from_str(json).unwrap();
704        assert_eq!(query.limit, 50);
705        assert_eq!(query.status, Some("pending".to_string()));
706        assert_eq!(query.entity_type, Some("scenario".to_string()));
707    }
708
709    #[test]
710    fn test_promotion_history_query_deserialization() {
711        let json = r#"{"workspace_id": "workspace-abc"}"#;
712
713        let query: PromotionHistoryQuery = serde_json::from_str(json).unwrap();
714        assert_eq!(query.workspace_id, "workspace-abc");
715    }
716
717    #[test]
718    fn test_default_limit_function() {
719        assert_eq!(default_limit(), 100);
720    }
721
722    #[test]
723    fn test_create_promotion_request_clone() {
724        let request = CreatePromotionRequest {
725            entity_type: "scenario".to_string(),
726            entity_id: "test-123".to_string(),
727            entity_version: Some("v1".to_string()),
728            workspace_id: "ws-1".to_string(),
729            from_environment: "dev".to_string(),
730            to_environment: "prod".to_string(),
731            requires_approval: Some(true),
732            scenario_tags: Some(vec!["tag1".to_string()]),
733            comments: Some("test".to_string()),
734            metadata: Some(serde_json::json!({"key": "value"})),
735        };
736
737        let cloned = request.clone();
738        assert_eq!(cloned.entity_type, request.entity_type);
739        assert_eq!(cloned.entity_id, request.entity_id);
740        assert_eq!(cloned.workspace_id, request.workspace_id);
741    }
742
743    #[test]
744    fn test_promotion_response_clone() {
745        let response = PromotionResponse {
746            promotion_id: "promo-1".to_string(),
747            entity_type: "scenario".to_string(),
748            entity_id: "scenario-1".to_string(),
749            entity_version: Some("v1".to_string()),
750            from_environment: "dev".to_string(),
751            to_environment: "prod".to_string(),
752            status: "pending".to_string(),
753            promoted_by: "user-1".to_string(),
754            approved_by: None,
755            comments: None,
756            pr_url: None,
757            timestamp: "2024-01-01T00:00:00Z".to_string(),
758        };
759
760        let cloned = response.clone();
761        assert_eq!(cloned.promotion_id, response.promotion_id);
762        assert_eq!(cloned.entity_type, response.entity_type);
763        assert_eq!(cloned.status, response.status);
764    }
765
766    #[test]
767    fn test_create_promotion_request_debug() {
768        let request = CreatePromotionRequest {
769            entity_type: "scenario".to_string(),
770            entity_id: "test-123".to_string(),
771            entity_version: None,
772            workspace_id: "ws-1".to_string(),
773            from_environment: "dev".to_string(),
774            to_environment: "prod".to_string(),
775            requires_approval: None,
776            scenario_tags: None,
777            comments: None,
778            metadata: None,
779        };
780
781        let debug_str = format!("{:?}", request);
782        assert!(debug_str.contains("test-123"));
783        assert!(debug_str.contains("ws-1"));
784    }
785
786    #[test]
787    fn test_promotion_response_debug() {
788        let response = PromotionResponse {
789            promotion_id: "promo-1".to_string(),
790            entity_type: "scenario".to_string(),
791            entity_id: "scenario-1".to_string(),
792            entity_version: None,
793            from_environment: "dev".to_string(),
794            to_environment: "prod".to_string(),
795            status: "pending".to_string(),
796            promoted_by: "user-1".to_string(),
797            approved_by: None,
798            comments: None,
799            pr_url: None,
800            timestamp: "2024-01-01T00:00:00Z".to_string(),
801        };
802
803        let debug_str = format!("{:?}", response);
804        assert!(debug_str.contains("promo-1"));
805        assert!(debug_str.contains("pending"));
806    }
807
808    #[test]
809    fn test_all_entity_types() {
810        let entity_types = vec!["scenario", "persona", "config"];
811        for entity_type in entity_types {
812            let json = format!(
813                r#"{{
814                "entity_type": "{}",
815                "entity_id": "test-id",
816                "workspace_id": "ws-1",
817                "from_environment": "dev",
818                "to_environment": "prod"
819            }}"#,
820                entity_type
821            );
822
823            let request: CreatePromotionRequest = serde_json::from_str(&json).unwrap();
824            assert_eq!(request.entity_type, entity_type);
825        }
826    }
827
828    #[test]
829    fn test_all_status_types() {
830        let statuses = vec!["pending", "approved", "rejected", "completed", "failed"];
831        for status in statuses {
832            let json = format!(r#"{{"status": "{}"}}"#, status);
833            let request: UpdatePromotionStatusRequest = serde_json::from_str(&json).unwrap();
834            assert_eq!(request.status, status);
835        }
836    }
837}