use std::str::FromStr; use cm_db::repo::{agent_template_link, agents, audit, credits, team_templates, users, workspaces}; use cm_db::DbError; use cm_domain::{ AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId, Workspace, WorkspaceId, }; fn workspace() -> Workspace { Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), } } fn user_in(ws: &Workspace, role: Role) -> User { User { id: UserId::new(), workspace_id: ws.id, email: format!("{}@acme.test", UserId::new()), role, display_name: "Test User".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, } } fn agent_in(ws: &Workspace, owner: &User) -> Agent { Agent { id: AgentId::new(), workspace_id: ws.id, name: "Scout".into(), job_title: "Research Analyst".into(), system_prompt: "You research things.".into(), avatar: "scout-1".into(), accent: "#f96565".into(), wallpaper: "dunes".into(), managed_by: owner.id, status: AgentStatus::Provisioning, } } #[tokio::test] async fn workspace_round_trips() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let found = workspaces::get(&pool, ws.id).await.unwrap(); assert_eq!(found, ws); } #[tokio::test] async fn missing_workspace_is_not_found() { let pool = cm_testkit::test_pool().await; let err = workspaces::get(&pool, WorkspaceId::new()) .await .unwrap_err(); assert!(matches!(err, DbError::NotFound)); } #[tokio::test] async fn user_round_trips_and_finds_by_email() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let user = user_in(&ws, Role::Owner); users::insert(&pool, &user).await.unwrap(); let by_id = users::get(&pool, user.id).await.unwrap(); assert_eq!(by_id.email, user.email); assert_eq!(by_id.role, Role::Owner); let by_email = users::find_by_email(&pool, &user.email).await.unwrap(); assert_eq!(by_email.id, user.id); } #[tokio::test] async fn duplicate_email_is_a_conflict() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let mut a = user_in(&ws, Role::Member); let mut b = user_in(&ws, Role::Member); b.email = a.email.clone(); users::insert(&pool, &a).await.unwrap(); let err = users::insert(&pool, &b).await.unwrap_err(); assert!(matches!(err, DbError::Conflict(_))); // Silence unused warnings for fields we only compare implicitly. a.display_name.clear(); } #[tokio::test] async fn workspace_members_lists_in_join_order() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); let member = user_in(&ws, Role::Member); users::insert(&pool, &owner).await.unwrap(); users::insert(&pool, &member).await.unwrap(); let members = users::list_by_workspace(&pool, ws.id).await.unwrap(); assert_eq!(members.len(), 2); assert_eq!(members[0].id, owner.id); assert_eq!(members[1].id, member.id); } #[tokio::test] async fn agent_insert_creates_default_access_policy() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); users::insert(&pool, &owner).await.unwrap(); let agent = agent_in(&ws, &owner); agents::insert(&pool, &agent, &AccessPolicy::default()) .await .unwrap(); let policy = agents::access_policy(&pool, agent.id).await.unwrap(); assert_eq!(policy.humans, HumanScope::EntireTeam); assert_eq!(policy.agents, AgentScope::Any); } #[tokio::test] async fn agent_roster_excludes_deleted_and_round_trips_fields() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); users::insert(&pool, &owner).await.unwrap(); let keep = agent_in(&ws, &owner); let remove = agent_in(&ws, &owner); agents::insert(&pool, &keep, &AccessPolicy::default()) .await .unwrap(); agents::insert(&pool, &remove, &AccessPolicy::default()) .await .unwrap(); agents::soft_delete(&pool, remove.id).await.unwrap(); let roster = agents::roster(&pool, ws.id).await.unwrap(); assert_eq!(roster.len(), 1); assert_eq!(roster[0], keep); } #[tokio::test] async fn agent_status_updates() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); users::insert(&pool, &owner).await.unwrap(); let agent = agent_in(&ws, &owner); agents::insert(&pool, &agent, &AccessPolicy::default()) .await .unwrap(); agents::set_status(&pool, agent.id, AgentStatus::Online) .await .unwrap(); let roster = agents::roster(&pool, ws.id).await.unwrap(); assert_eq!(roster[0].status, AgentStatus::Online); } #[tokio::test] async fn specific_access_policy_round_trips() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); users::insert(&pool, &owner).await.unwrap(); let agent = agent_in(&ws, &owner); let policy = AccessPolicy { humans: HumanScope::Specific(vec![owner.id]), agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]), }; agents::insert(&pool, &agent, &policy).await.unwrap(); let stored = agents::access_policy(&pool, agent.id).await.unwrap(); assert_eq!(stored, policy); } #[tokio::test] async fn credit_balance_sums_remaining_lots() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0); credits::add_lot(&pool, ws.id, 1000, "purchase") .await .unwrap(); credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap(); assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250); } #[tokio::test] async fn audit_log_appends_and_rejects_mutation() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let entry_id = audit::append( &pool, ws.id, audit::Actor::System, "workspace.created", "workspace", &ws.id.to_string(), serde_json::json!({"plan": "team"}), ) .await .unwrap(); assert!(entry_id > 0); // Append-only is enforced by the database itself, not convention. let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1") .bind(entry_id) .execute(&pool) .await; assert!(update.is_err()); let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1") .bind(entry_id) .execute(&pool) .await; assert!(delete.is_err()); } /// A template must stay editable after it has minted agents. /// /// `agent_template_link` holds a plain FK on (template_id, role_slot), so the /// old delete-then-reinsert upsert was rejected the moment a template had been /// used — and because the loader logs and continues, the on-disk TOML and the /// DB drifted apart silently, for exactly the templates anyone actually ran. #[tokio::test] async fn a_template_with_live_agents_still_accepts_edits() { let pool = cm_testkit::test_pool().await; let ws = workspace(); workspaces::insert(&pool, &ws).await.unwrap(); let owner = user_in(&ws, Role::Owner); users::insert(&pool, &owner).await.unwrap(); let id = uuid::Uuid::now_v7(); let build = |prompt: &'static str, extra: Vec| team_templates::UpsertBuiltin { id, key: "fixture_team", name: "Fixture", stack: vec!["rust".into()], default_topology: "pipeline", risk_profile: "medium", mcp_bundles: vec![], version: 1, description: None, config: serde_json::json!({}), category: "development", roles: vec![team_templates::UpsertBuiltinRole { slot: "coder", order_idx: 0, system_prompt: prompt, skills: extra, brain_seed: None, }], }; team_templates::upsert_builtin(&pool, build("first", vec![])) .await .unwrap(); // Mint an agent against the template — this is what a mission does. let agent = Agent { id: AgentId::new(), workspace_id: ws.id, name: "Fixture · coder".into(), job_title: "coder".into(), system_prompt: "first".into(), avatar: String::new(), accent: "#fff".into(), wallpaper: String::new(), managed_by: owner.id, status: AgentStatus::Online, }; agents::insert(&pool, &agent, &AccessPolicy::default()) .await .unwrap(); agent_template_link::upsert(&pool, agent.id.as_uuid(), id, 1, "coder") .await .unwrap(); // The edit that used to fail with a foreign-key violation. team_templates::upsert_builtin( &pool, build("second", vec!["write-rust-current-edition".into()]), ) .await .expect("a used template must still accept edits"); let detail = team_templates::get(&pool, id).await.unwrap().unwrap(); let role = detail.roles.iter().find(|r| r.slot == "coder").unwrap(); assert_eq!(role.system_prompt, "second", "the prompt edit applied"); assert_eq!( role.skills, vec!["write-rust-current-edition".to_string()], "the skill edit applied", ); }