1use 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#[derive(Clone)]
27pub struct PromotionState {
28 pub promotion_service: Arc<PromotionService>,
30 pub workspace_state: WorkspaceState,
32}
33
34impl PromotionState {
35 pub fn new(promotion_service: Arc<PromotionService>, workspace_state: WorkspaceState) -> Self {
37 Self {
38 promotion_service,
39 workspace_state,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Deserialize)]
46pub struct CreatePromotionRequest {
47 pub entity_type: String,
49 pub entity_id: String,
51 pub entity_version: Option<String>,
53 pub workspace_id: String,
55 pub from_environment: String,
57 pub to_environment: String,
59 pub requires_approval: Option<bool>,
61 #[serde(default)]
63 pub scenario_tags: Option<Vec<String>>,
64 pub comments: Option<String>,
66 pub metadata: Option<serde_json::Value>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PromotionResponse {
73 pub promotion_id: String,
75 pub entity_type: String,
77 pub entity_id: String,
79 pub entity_version: Option<String>,
81 pub from_environment: String,
83 pub to_environment: String,
85 pub status: String,
87 pub promoted_by: String,
89 pub approved_by: Option<String>,
91 pub comments: Option<String>,
93 pub pr_url: Option<String>,
95 pub timestamp: String,
97}
98
99#[derive(Debug, Clone, Deserialize)]
101pub struct UpdatePromotionStatusRequest {
102 pub status: String,
104 pub approved_by: Option<String>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110pub struct ListPromotionsQuery {
111 #[serde(default = "default_limit")]
113 pub limit: i64,
114 pub status: Option<String>,
116 pub entity_type: Option<String>,
118}
119
120fn default_limit() -> i64 {
121 100
122}
123
124pub async fn create_promotion(
128 State(state): State<PromotionState>,
129 headers: HeaderMap,
130 Json(body): Json<CreatePromotionRequest>,
131) -> Result<Json<ApiResponse<PromotionResponse>>, Response> {
132 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 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 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 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 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 (true, Some("Approval required for promotion".to_string()))
203 };
204
205 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 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 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 let workspace_config = {
242 let registry = state.workspace_state.registry.read().await;
243 if let Ok(workspace) = registry.get_workspace(&body.workspace_id) {
244 serde_json::to_value(&workspace.workspace.config).ok()
246 } else {
247 None
248 }
249 };
250
251 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 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, timestamp: chrono::Utc::now().to_rfc3339(),
277 };
278
279 Ok(Json(ApiResponse::success(response)))
280}
281
282pub 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 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
320pub 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 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 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 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 match state
369 .promotion_service
370 .update_promotion_status(promotion_uuid, status, approver_id)
371 .await
372 {
373 Ok(_) => {
374 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
405pub 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 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 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
459pub 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#[derive(Debug, Clone, Deserialize)]
495pub struct PromotionHistoryQuery {
496 pub workspace_id: String,
498}
499
500pub 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 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); 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}