use crate::error::{CollabError, Result};
use chrono::{DateTime, Utc};
use mockforge_core::pr_generation::{
PRFileChange, PRFileChangeType, PRGenerator, PRProvider, PRRequest,
};
use mockforge_core::workspace::mock_environment::MockEnvironmentName;
use mockforge_core::workspace::scenario_promotion::{
PromotionEntityType, PromotionHistory, PromotionHistoryEntry, PromotionRequest, PromotionStatus,
};
use mockforge_core::PromotionService as PromotionServiceTrait;
use serde_json;
use sqlx::{Pool, Sqlite};
use std::fmt::Write;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct PromotionGitOpsConfig {
pub enabled: bool,
pub pr_generator: Option<PRGenerator>,
pub config_path: Option<String>,
}
impl PromotionGitOpsConfig {
#[must_use]
pub fn new(
enabled: bool,
provider: PRProvider,
owner: String,
repo: String,
token: Option<String>,
base_branch: String,
config_path: Option<String>,
) -> Self {
let pr_generator = if let (true, Some(token)) = (enabled, token) {
Some(match provider {
PRProvider::GitHub => PRGenerator::new_github(owner, repo, token, base_branch),
PRProvider::GitLab => PRGenerator::new_gitlab(owner, repo, token, base_branch),
})
} else {
None
};
Self {
enabled,
pr_generator,
config_path,
}
}
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
pr_generator: None,
config_path: None,
}
}
}
pub struct PromotionService {
db: Pool<Sqlite>,
gitops: Arc<PromotionGitOpsConfig>,
}
impl PromotionService {
#[must_use]
pub fn new(db: Pool<Sqlite>) -> Self {
Self {
db,
gitops: Arc::new(PromotionGitOpsConfig::disabled()),
}
}
#[must_use]
pub fn with_gitops(db: Pool<Sqlite>, gitops: PromotionGitOpsConfig) -> Self {
Self {
db,
gitops: Arc::new(gitops),
}
}
#[allow(clippy::unused_async)]
pub async fn run_migrations(&self) -> Result<()> {
Ok(())
}
pub async fn record_promotion(
&self,
request: &PromotionRequest,
promoted_by: Uuid,
status: PromotionStatus,
workspace_config: Option<serde_json::Value>,
) -> Result<Uuid> {
let promotion_id = Uuid::new_v4();
let now = Utc::now();
let metadata_json = if request.metadata.is_empty() {
None
} else {
Some(serde_json::to_string(&request.metadata)?)
};
let promotion_id_str = promotion_id.to_string();
let promoted_by_str = promoted_by.to_string();
let entity_type_str = request.entity_type.to_string();
let from_env_str = request.from_environment.as_str().to_string();
let to_env_str = request.to_environment.as_str().to_string();
let status_str = status.to_string();
let now_str = now.to_rfc3339();
sqlx::query!(
r#"
INSERT INTO promotion_history (
id, workspace_id, entity_type, entity_id, entity_version,
from_environment, to_environment, promoted_by, status,
comments, metadata, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
promotion_id_str,
request.workspace_id,
entity_type_str,
request.entity_id,
request.entity_version,
from_env_str,
to_env_str,
promoted_by_str,
status_str,
request.comments,
metadata_json,
now_str,
now_str,
)
.execute(&self.db)
.await
.map_err(|e| CollabError::DatabaseError(format!("Failed to record promotion: {e}")))?;
if let (true, Some(config)) = (self.gitops.enabled, workspace_config) {
if let Err(e) = self.create_promotion_pr(&promotion_id, request, config).await {
tracing::warn!("Failed to create GitOps PR for promotion {}: {}", promotion_id, e);
}
}
Ok(promotion_id)
}
async fn create_promotion_pr(
&self,
promotion_id: &Uuid,
request: &PromotionRequest,
workspace_config: serde_json::Value,
) -> Result<()> {
let pr_generator = self
.gitops
.pr_generator
.as_ref()
.ok_or_else(|| CollabError::Internal("PR generator not configured".to_string()))?;
let title = format!(
"Promote {} '{}' from {} to {}",
request.entity_type,
request.entity_id,
request.from_environment.as_str(),
request.to_environment.as_str(),
);
let mut body = format!(
"## Promotion: {} → {}\n\n",
request.from_environment.as_str(),
request.to_environment.as_str(),
);
let _ = writeln!(body, "**Entity Type:** {}", request.entity_type);
let _ = writeln!(body, "**Entity ID:** {}", request.entity_id);
if let Some(version) = &request.entity_version {
let _ = writeln!(body, "**Version:** {version}");
}
if let Some(comments) = &request.comments {
let _ = write!(body, "\n**Comments:**\n{comments}\n");
}
body.push_str("\n---\n\n");
body.push_str("*This PR was automatically generated by MockForge promotion workflow.*");
let default_path = format!("workspaces/{}/config.yaml", request.workspace_id);
let config_path = self.gitops.config_path.as_deref().unwrap_or(&default_path);
let config_json = serde_json::to_string_pretty(&workspace_config)
.map_err(|e| CollabError::Internal(format!("Failed to serialize config: {e}")))?;
let file_path = if std::path::Path::new(config_path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
{
config_path.to_string()
} else {
format!("{config_path}.json")
};
let file_change = PRFileChange {
path: file_path,
content: config_json,
change_type: PRFileChangeType::Update,
};
let pr_request = PRRequest {
title,
body,
branch: format!(
"mockforge/promotion-{}-{}-{}",
request.entity_type,
request.entity_id,
&promotion_id.to_string()[..8]
),
files: vec![file_change],
labels: vec![
"automated".to_string(),
"promotion".to_string(),
format!("env-{}", request.to_environment.as_str()),
],
reviewers: vec![],
};
match pr_generator.create_pr(pr_request).await {
Ok(pr_result) => {
self.update_promotion_pr_url(*promotion_id, pr_result.url.clone()).await?;
tracing::info!(
"Created GitOps PR {} for promotion {}",
pr_result.url,
promotion_id
);
Ok(())
}
Err(e) => {
tracing::error!("Failed to create PR for promotion {}: {}", promotion_id, e);
Err(CollabError::Internal(format!("Failed to create PR: {e}")))
}
}
}
pub async fn update_promotion_status(
&self,
promotion_id: Uuid,
status: PromotionStatus,
approved_by: Option<Uuid>,
) -> Result<()> {
let now = Utc::now();
let status_str = status.to_string();
let approved_by_str = approved_by.map(|u| u.to_string());
let now_str = now.to_rfc3339();
let promotion_id_str = promotion_id.to_string();
sqlx::query!(
r#"
UPDATE promotion_history
SET status = ?, approved_by = ?, updated_at = ?
WHERE id = ?
"#,
status_str,
approved_by_str,
now_str,
promotion_id_str,
)
.execute(&self.db)
.await
.map_err(|e| {
CollabError::DatabaseError(format!("Failed to update promotion status: {e}"))
})?;
if status == PromotionStatus::Completed {
#[cfg(feature = "pipelines")]
{
use mockforge_pipelines::events::{publish_event, PipelineEvent};
use sqlx::Row;
let workspace_id_row =
sqlx::query("SELECT workspace_id FROM promotion_history WHERE id = ?")
.bind(&promotion_id_str)
.fetch_optional(&self.db)
.await
.ok()
.flatten();
if let Some(row) = workspace_id_row {
if let Ok(workspace_id_str) = row.try_get::<String, _>("workspace_id") {
if let Ok(ws_id) = Uuid::parse_str(&workspace_id_str) {
if let Some(promotion) = self.get_promotion_by_id(promotion_id).await? {
let event = PipelineEvent::promotion_completed(
ws_id,
promotion_id,
promotion.entity_type.to_string(),
promotion.from_environment.as_str().to_string(),
promotion.to_environment.as_str().to_string(),
);
if let Err(e) = publish_event(event) {
tracing::warn!(
"Failed to publish promotion completed event: {}",
e
);
}
}
}
}
}
}
}
Ok(())
}
pub async fn update_promotion_pr_url(&self, promotion_id: Uuid, pr_url: String) -> Result<()> {
let now = Utc::now();
let now_str = now.to_rfc3339();
let promotion_id_str = promotion_id.to_string();
sqlx::query!(
r#"
UPDATE promotion_history
SET pr_url = ?, updated_at = ?
WHERE id = ?
"#,
pr_url,
now_str,
promotion_id_str,
)
.execute(&self.db)
.await
.map_err(|e| {
CollabError::DatabaseError(format!("Failed to update promotion PR URL: {e}"))
})?;
Ok(())
}
#[allow(clippy::items_after_statements)]
pub async fn get_promotion_by_id(
&self,
promotion_id: Uuid,
) -> Result<Option<PromotionHistoryEntry>> {
let promotion_id_str = promotion_id.to_string();
use sqlx::Row;
let row = sqlx::query(
r"
SELECT
id, entity_type, entity_id, entity_version, workspace_id,
from_environment, to_environment, promoted_by, approved_by,
status, comments, pr_url, metadata, created_at, updated_at
FROM promotion_history
WHERE id = ?
",
)
.bind(&promotion_id_str)
.fetch_optional(&self.db)
.await
.map_err(|e| CollabError::DatabaseError(format!("Failed to get promotion: {e}")))?;
if let Some(row) = row {
let id: String = row.get("id");
let entity_type_str: String = row.get("entity_type");
let entity_id: String = row.get("entity_id");
let entity_version: Option<String> = row.get("entity_version");
let _workspace_id: String = row.get("workspace_id");
let from_environment: String = row.get("from_environment");
let to_environment: String = row.get("to_environment");
let promoted_by: String = row.get("promoted_by");
let approved_by: Option<String> = row.get("approved_by");
let status_str: String = row.get("status");
let comments: Option<String> = row.get("comments");
let pr_url: Option<String> = row.get("pr_url");
let metadata: Option<String> = row.get("metadata");
let created_at: String = row.get("created_at");
let from_env = MockEnvironmentName::from_str(&from_environment).ok_or_else(|| {
CollabError::Internal(format!("Invalid from_environment: {from_environment}"))
})?;
let to_env = MockEnvironmentName::from_str(&to_environment).ok_or_else(|| {
CollabError::Internal(format!("Invalid to_environment: {to_environment}"))
})?;
let status = match status_str.as_str() {
"pending" => PromotionStatus::Pending,
"approved" => PromotionStatus::Approved,
"rejected" => PromotionStatus::Rejected,
"completed" => PromotionStatus::Completed,
"failed" => PromotionStatus::Failed,
_ => return Err(CollabError::Internal(format!("Invalid status: {status_str}"))),
};
let entity_type = match entity_type_str.as_str() {
"scenario" => PromotionEntityType::Scenario,
"persona" => PromotionEntityType::Persona,
"config" => PromotionEntityType::Config,
_ => {
return Err(CollabError::Internal(format!(
"Invalid entity_type: {entity_type_str}"
)))
}
};
let metadata_map = metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
serde_json::from_str(&meta_str).unwrap_or_default()
});
let timestamp = DateTime::parse_from_rfc3339(&created_at)
.map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
.with_timezone(&Utc);
Ok(Some(PromotionHistoryEntry {
promotion_id: id,
entity_type,
entity_id,
entity_version,
from_environment: from_env,
to_environment: to_env,
promoted_by,
approved_by,
status,
timestamp,
comments,
pr_url,
metadata: metadata_map,
}))
} else {
Ok(None)
}
}
pub async fn get_promotion_history(
&self,
workspace_id: &str,
entity_type: PromotionEntityType,
entity_id: &str,
) -> Result<PromotionHistory> {
let entity_type_str = entity_type.to_string();
let rows = sqlx::query!(
r#"
SELECT
id, entity_type, entity_id, entity_version,
from_environment, to_environment, promoted_by, approved_by,
status, comments, pr_url, metadata, created_at, updated_at
FROM promotion_history
WHERE workspace_id = ? AND entity_type = ? AND entity_id = ?
ORDER BY created_at ASC
"#,
workspace_id,
entity_type_str,
entity_id,
)
.fetch_all(&self.db)
.await
.map_err(|e| CollabError::DatabaseError(format!("Failed to get promotion history: {e}")))?;
let promotions: Result<Vec<PromotionHistoryEntry>> = rows
.into_iter()
.map(|row| {
let from_env =
MockEnvironmentName::from_str(&row.from_environment).ok_or_else(|| {
CollabError::Internal(format!(
"Invalid from_environment: {}",
row.from_environment
))
})?;
let to_env =
MockEnvironmentName::from_str(&row.to_environment).ok_or_else(|| {
CollabError::Internal(format!(
"Invalid to_environment: {}",
row.to_environment
))
})?;
let status = match row.status.as_str() {
"pending" => PromotionStatus::Pending,
"approved" => PromotionStatus::Approved,
"rejected" => PromotionStatus::Rejected,
"completed" => PromotionStatus::Completed,
"failed" => PromotionStatus::Failed,
_ => {
return Err(CollabError::Internal(format!(
"Invalid status: {}",
row.status
)))
}
};
let entity_type = match row.entity_type.as_str() {
"scenario" => PromotionEntityType::Scenario,
"persona" => PromotionEntityType::Persona,
"config" => PromotionEntityType::Config,
_ => {
return Err(CollabError::Internal(format!(
"Invalid entity_type: {}",
row.entity_type
)))
}
};
let metadata =
row.metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
serde_json::from_str(&meta_str).unwrap_or_default()
});
let timestamp = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
.with_timezone(&Utc);
Ok(PromotionHistoryEntry {
promotion_id: row.id,
entity_type,
entity_id: row.entity_id,
entity_version: row.entity_version,
from_environment: from_env,
to_environment: to_env,
promoted_by: row.promoted_by,
approved_by: row.approved_by,
status,
timestamp,
comments: row.comments,
pr_url: row.pr_url,
metadata,
})
})
.collect();
Ok(PromotionHistory {
entity_type,
entity_id: entity_id.to_string(),
workspace_id: workspace_id.to_string(),
promotions: promotions?,
})
}
pub async fn get_workspace_promotions(
&self,
workspace_id: &str,
limit: Option<i64>,
) -> Result<Vec<PromotionHistoryEntry>> {
let limit = limit.unwrap_or(100);
let rows = sqlx::query!(
r#"
SELECT
id, entity_type, entity_id, entity_version,
from_environment, to_environment, promoted_by, approved_by,
status, comments, pr_url, metadata, created_at, updated_at
FROM promotion_history
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT ?
"#,
workspace_id,
limit,
)
.fetch_all(&self.db)
.await
.map_err(|e| {
CollabError::DatabaseError(format!("Failed to get workspace promotions: {e}"))
})?;
let promotions: Result<Vec<PromotionHistoryEntry>> = rows
.into_iter()
.map(|row| {
let from_env =
MockEnvironmentName::from_str(&row.from_environment).ok_or_else(|| {
CollabError::Internal(format!(
"Invalid from_environment: {}",
row.from_environment
))
})?;
let to_env =
MockEnvironmentName::from_str(&row.to_environment).ok_or_else(|| {
CollabError::Internal(format!(
"Invalid to_environment: {}",
row.to_environment
))
})?;
let status = match row.status.as_str() {
"pending" => PromotionStatus::Pending,
"approved" => PromotionStatus::Approved,
"rejected" => PromotionStatus::Rejected,
"completed" => PromotionStatus::Completed,
"failed" => PromotionStatus::Failed,
_ => {
return Err(CollabError::Internal(format!(
"Invalid status: {}",
row.status
)))
}
};
let entity_type = match row.entity_type.as_str() {
"scenario" => PromotionEntityType::Scenario,
"persona" => PromotionEntityType::Persona,
"config" => PromotionEntityType::Config,
_ => {
return Err(CollabError::Internal(format!(
"Invalid entity_type: {}",
row.entity_type
)))
}
};
let metadata =
row.metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
serde_json::from_str(&meta_str).unwrap_or_default()
});
let timestamp = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
.with_timezone(&Utc);
Ok(PromotionHistoryEntry {
promotion_id: row.id,
entity_type,
entity_id: row.entity_id,
entity_version: row.entity_version,
from_environment: from_env,
to_environment: to_env,
promoted_by: row.promoted_by,
approved_by: row.approved_by,
status,
timestamp,
comments: row.comments,
pr_url: row.pr_url,
metadata,
})
})
.collect();
promotions
}
#[allow(clippy::items_after_statements)]
pub async fn get_pending_promotions(
&self,
workspace_id: Option<&str>,
) -> Result<Vec<PromotionHistoryEntry>> {
let rows = if let Some(ws_id) = workspace_id {
sqlx::query(
r"
SELECT
id, entity_type, entity_id, entity_version,
from_environment, to_environment, promoted_by, approved_by,
status, comments, pr_url, metadata, created_at, updated_at
FROM promotion_history
WHERE workspace_id = ? AND status = 'pending'
ORDER BY created_at ASC
",
)
.bind(ws_id)
.fetch_all(&self.db)
.await
.map_err(|e| {
CollabError::DatabaseError(format!("Failed to get pending promotions: {e}"))
})?
} else {
sqlx::query(
r"
SELECT
id, entity_type, entity_id, entity_version,
from_environment, to_environment, promoted_by, approved_by,
status, comments, pr_url, metadata, created_at, updated_at
FROM promotion_history
WHERE status = 'pending'
ORDER BY created_at ASC
",
)
.fetch_all(&self.db)
.await
.map_err(|e| {
CollabError::DatabaseError(format!("Failed to get pending promotions: {e}"))
})?
};
use sqlx::Row;
let promotions: Result<Vec<PromotionHistoryEntry>> = rows
.into_iter()
.map(|row: sqlx::sqlite::SqliteRow| {
let id: String = row.get("id");
let entity_type_str: String = row.get("entity_type");
let entity_id: String = row.get("entity_id");
let entity_version: Option<String> = row.get("entity_version");
let from_environment: String = row.get("from_environment");
let to_environment: String = row.get("to_environment");
let promoted_by: String = row.get("promoted_by");
let approved_by: Option<String> = row.get("approved_by");
let comments: Option<String> = row.get("comments");
let pr_url: Option<String> = row.get("pr_url");
let metadata: Option<String> = row.get("metadata");
let created_at: String = row.get("created_at");
let from_env =
MockEnvironmentName::from_str(&from_environment).ok_or_else(|| {
CollabError::Internal(format!(
"Invalid from_environment: {from_environment}"
))
})?;
let to_env = MockEnvironmentName::from_str(&to_environment).ok_or_else(|| {
CollabError::Internal(format!("Invalid to_environment: {to_environment}"))
})?;
let status = PromotionStatus::Pending;
let entity_type = match entity_type_str.as_str() {
"scenario" => PromotionEntityType::Scenario,
"persona" => PromotionEntityType::Persona,
"config" => PromotionEntityType::Config,
_ => {
return Err(CollabError::Internal(format!(
"Invalid entity_type: {entity_type_str}"
)))
}
};
let metadata_map = metadata
.map_or_else(std::collections::HashMap::new, |meta_str| {
serde_json::from_str(&meta_str).unwrap_or_default()
});
let timestamp = DateTime::parse_from_rfc3339(&created_at)
.map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
.with_timezone(&Utc);
Ok(PromotionHistoryEntry {
promotion_id: id,
entity_type,
entity_id,
entity_version,
from_environment: from_env,
to_environment: to_env,
promoted_by,
approved_by,
status,
timestamp,
comments,
pr_url,
metadata: metadata_map,
})
})
.collect();
promotions
}
}
#[async_trait::async_trait]
impl PromotionServiceTrait for PromotionService {
async fn promote_entity(
&self,
workspace_id: Uuid,
entity_type: PromotionEntityType,
entity_id: String,
entity_version: Option<String>,
from_environment: MockEnvironmentName,
to_environment: MockEnvironmentName,
promoted_by: Uuid,
comments: Option<String>,
) -> mockforge_core::Result<Uuid> {
let request = PromotionRequest {
entity_type,
entity_id: entity_id.clone(),
entity_version,
workspace_id: workspace_id.to_string(),
from_environment,
to_environment,
requires_approval: false, approval_required_reason: None,
comments,
metadata: std::collections::HashMap::new(),
};
self.record_promotion(&request, promoted_by, PromotionStatus::Completed, None)
.await
.map_err(|e| mockforge_core::Error::generic(format!("Promotion failed: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockforge_core::pr_generation::PRProvider;
use mockforge_core::workspace::mock_environment::MockEnvironmentName;
use mockforge_core::workspace::scenario_promotion::{
PromotionEntityType, PromotionRequest, PromotionStatus,
};
use sqlx::SqlitePool;
async fn setup_test_db() -> Pool<Sqlite> {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
r"
CREATE TABLE IF NOT EXISTS promotion_history (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_version TEXT,
from_environment TEXT NOT NULL,
to_environment TEXT NOT NULL,
promoted_by TEXT NOT NULL,
approved_by TEXT,
status TEXT NOT NULL,
comments TEXT,
pr_url TEXT,
metadata TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
",
)
.execute(&pool)
.await
.unwrap();
pool
}
#[test]
fn test_promotion_gitops_config_new() {
let config = PromotionGitOpsConfig::new(
true,
PRProvider::GitHub,
"owner".to_string(),
"repo".to_string(),
Some("token".to_string()),
"main".to_string(),
Some("config.yaml".to_string()),
);
assert!(config.enabled);
assert!(config.pr_generator.is_some());
assert_eq!(config.config_path, Some("config.yaml".to_string()));
}
#[test]
fn test_promotion_gitops_config_new_without_token() {
let config = PromotionGitOpsConfig::new(
true,
PRProvider::GitHub,
"owner".to_string(),
"repo".to_string(),
None,
"main".to_string(),
None,
);
assert!(config.enabled);
assert!(config.pr_generator.is_none());
assert_eq!(config.config_path, None);
}
#[test]
fn test_promotion_gitops_config_disabled() {
let config = PromotionGitOpsConfig::disabled();
assert!(!config.enabled);
assert!(config.pr_generator.is_none());
assert_eq!(config.config_path, None);
}
#[test]
fn test_promotion_gitops_config_gitlab() {
let config = PromotionGitOpsConfig::new(
true,
PRProvider::GitLab,
"owner".to_string(),
"repo".to_string(),
Some("token".to_string()),
"main".to_string(),
None,
);
assert!(config.enabled);
assert!(config.pr_generator.is_some());
}
#[tokio::test]
async fn test_promotion_service_new() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
assert!(!service.gitops.enabled);
}
#[tokio::test]
async fn test_promotion_service_with_gitops() {
let pool = setup_test_db().await;
let gitops = PromotionGitOpsConfig::disabled();
let service = PromotionService::with_gitops(pool, gitops);
assert!(!service.gitops.enabled);
}
#[tokio::test]
async fn test_run_migrations() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let result = service.run_migrations().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_record_promotion_success() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: "test-scenario".to_string(),
entity_version: Some("v1".to_string()),
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: Some("Test promotion".to_string()),
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
let result = service
.record_promotion(&request, user_id, PromotionStatus::Pending, None)
.await;
assert!(result.is_ok());
let promotion_id = result.unwrap();
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
let promotion = promotion.unwrap();
assert_eq!(promotion.entity_id, "test-scenario");
assert_eq!(promotion.status, PromotionStatus::Pending);
}
#[tokio::test]
async fn test_record_promotion_with_metadata() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let mut metadata = std::collections::HashMap::new();
metadata.insert("key1".to_string(), serde_json::Value::String("value1".to_string()));
metadata.insert("key2".to_string(), serde_json::Value::String("value2".to_string()));
let request = PromotionRequest {
entity_type: PromotionEntityType::Persona,
entity_id: "test-persona".to_string(),
entity_version: None,
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Test,
to_environment: MockEnvironmentName::Prod,
requires_approval: true,
approval_required_reason: Some("Production deployment".to_string()),
comments: None,
metadata,
};
let user_id = Uuid::new_v4();
let result = service
.record_promotion(&request, user_id, PromotionStatus::Pending, None)
.await;
assert!(result.is_ok());
let promotion_id = result.unwrap();
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
let promotion = promotion.unwrap();
assert_eq!(promotion.metadata.len(), 2);
assert_eq!(promotion.metadata.get("key1").unwrap(), "value1");
}
#[tokio::test]
async fn test_update_promotion_status() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let request = PromotionRequest {
entity_type: PromotionEntityType::Config,
entity_id: "test-config".to_string(),
entity_version: None,
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: true,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
let promotion_id = service
.record_promotion(&request, user_id, PromotionStatus::Pending, None)
.await
.unwrap();
let approver_id = Uuid::new_v4();
let result = service
.update_promotion_status(promotion_id, PromotionStatus::Approved, Some(approver_id))
.await;
assert!(result.is_ok());
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
let promotion = promotion.unwrap();
assert_eq!(promotion.status, PromotionStatus::Approved);
assert_eq!(promotion.approved_by, Some(approver_id.to_string()));
}
#[tokio::test]
async fn test_update_promotion_pr_url() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: "test-scenario".to_string(),
entity_version: None,
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
let promotion_id = service
.record_promotion(&request, user_id, PromotionStatus::Pending, None)
.await
.unwrap();
let pr_url = "https://github.com/owner/repo/pull/123".to_string();
let result = service.update_promotion_pr_url(promotion_id, pr_url.clone()).await;
assert!(result.is_ok());
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
let promotion = promotion.unwrap();
assert_eq!(promotion.pr_url, Some(pr_url));
}
#[tokio::test]
async fn test_get_promotion_by_id_not_found() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let result = service.get_promotion_by_id(Uuid::new_v4()).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_get_promotion_history() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let workspace_id = Uuid::new_v4();
let entity_id = "test-scenario";
for i in 0..3 {
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: entity_id.to_string(),
entity_version: Some(format!("v{i}")),
workspace_id: workspace_id.to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: Some(format!("Promotion {i}")),
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
service
.record_promotion(&request, user_id, PromotionStatus::Completed, None)
.await
.unwrap();
}
let history = service
.get_promotion_history(
&workspace_id.to_string(),
PromotionEntityType::Scenario,
entity_id,
)
.await
.unwrap();
assert_eq!(history.promotions.len(), 3);
assert_eq!(history.entity_id, entity_id);
assert_eq!(history.workspace_id, workspace_id.to_string());
}
#[tokio::test]
async fn test_get_workspace_promotions() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let workspace_id = Uuid::new_v4();
for entity_type in &[
PromotionEntityType::Scenario,
PromotionEntityType::Persona,
PromotionEntityType::Config,
] {
let request = PromotionRequest {
entity_type: *entity_type,
entity_id: format!("test-{entity_type}"),
entity_version: None,
workspace_id: workspace_id.to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
service
.record_promotion(&request, user_id, PromotionStatus::Completed, None)
.await
.unwrap();
}
let promotions =
service.get_workspace_promotions(&workspace_id.to_string(), None).await.unwrap();
assert_eq!(promotions.len(), 3);
}
#[tokio::test]
async fn test_get_workspace_promotions_with_limit() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let workspace_id = Uuid::new_v4();
for i in 0..5 {
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: format!("test-{i}"),
entity_version: None,
workspace_id: workspace_id.to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
service
.record_promotion(&request, user_id, PromotionStatus::Completed, None)
.await
.unwrap();
}
let promotions = service
.get_workspace_promotions(&workspace_id.to_string(), Some(3))
.await
.unwrap();
assert_eq!(promotions.len(), 3);
}
#[tokio::test]
async fn test_get_pending_promotions() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let workspace_id = Uuid::new_v4();
for (i, status) in [
PromotionStatus::Pending,
PromotionStatus::Approved,
PromotionStatus::Pending,
PromotionStatus::Completed,
]
.iter()
.enumerate()
{
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: format!("test-{i}"),
entity_version: None,
workspace_id: workspace_id.to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: true,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
service.record_promotion(&request, user_id, *status, None).await.unwrap();
}
let pending =
service.get_pending_promotions(Some(&workspace_id.to_string())).await.unwrap();
assert_eq!(pending.len(), 2);
for promotion in &pending {
assert_eq!(promotion.status, PromotionStatus::Pending);
}
}
#[tokio::test]
async fn test_get_pending_promotions_all_workspaces() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
for _ in 0..3 {
let workspace_id = Uuid::new_v4();
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: "test-scenario".to_string(),
entity_version: None,
workspace_id: workspace_id.to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: true,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
service
.record_promotion(&request, user_id, PromotionStatus::Pending, None)
.await
.unwrap();
}
let pending = service.get_pending_promotions(None).await.unwrap();
assert_eq!(pending.len(), 3);
}
#[tokio::test]
async fn test_promotion_service_trait_promote_entity() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let workspace_id = Uuid::new_v4();
let user_id = Uuid::new_v4();
let result = service
.promote_entity(
workspace_id,
PromotionEntityType::Scenario,
"test-scenario".to_string(),
Some("v1".to_string()),
MockEnvironmentName::Dev,
MockEnvironmentName::Test,
user_id,
Some("Auto promotion".to_string()),
)
.await;
assert!(result.is_ok());
let promotion_id = result.unwrap();
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
let promotion = promotion.unwrap();
assert_eq!(promotion.status, PromotionStatus::Completed);
assert_eq!(promotion.entity_id, "test-scenario");
}
#[tokio::test]
async fn test_all_promotion_statuses() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let statuses = vec![
PromotionStatus::Pending,
PromotionStatus::Approved,
PromotionStatus::Rejected,
PromotionStatus::Completed,
PromotionStatus::Failed,
];
for status in statuses {
let request = PromotionRequest {
entity_type: PromotionEntityType::Scenario,
entity_id: format!("test-{status}"),
entity_version: None,
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
let promotion_id =
service.record_promotion(&request, user_id, status, None).await.unwrap();
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
assert_eq!(promotion.unwrap().status, status);
}
}
#[tokio::test]
async fn test_all_entity_types() {
let pool = setup_test_db().await;
let service = PromotionService::new(pool);
let entity_types = vec![
PromotionEntityType::Scenario,
PromotionEntityType::Persona,
PromotionEntityType::Config,
];
for entity_type in entity_types {
let request = PromotionRequest {
entity_type,
entity_id: format!("test-{entity_type}"),
entity_version: None,
workspace_id: Uuid::new_v4().to_string(),
from_environment: MockEnvironmentName::Dev,
to_environment: MockEnvironmentName::Test,
requires_approval: false,
approval_required_reason: None,
comments: None,
metadata: std::collections::HashMap::new(),
};
let user_id = Uuid::new_v4();
let promotion_id = service
.record_promotion(&request, user_id, PromotionStatus::Completed, None)
.await
.unwrap();
let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
assert!(promotion.is_some());
assert_eq!(promotion.unwrap().entity_type, entity_type);
}
}
}