//! Entity & value-object invariant tests: valid construction plus the expected //! rejections (ARCHITECTURE §3.2). mod helpers; use domain::{ Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion, }; use helpers::{AtomicSeqIdGenerator, FixedClock}; use uuid::Uuid; fn profile_id() -> ProfileId { ProfileId::from_uuid(Uuid::from_u128(42)) } fn template_id() -> TemplateId { TemplateId::from_uuid(Uuid::from_u128(7)) } // --------------------------------------------------------------------------- // ProjectPath // --------------------------------------------------------------------------- #[test] fn project_path_accepts_posix_absolute() { assert!(ProjectPath::new("/home/user/proj").is_ok()); } #[test] fn project_path_accepts_windows_drive_and_unc() { assert!(ProjectPath::new("C:\\Users\\x").is_ok()); assert!(ProjectPath::new("C:/Users/x").is_ok()); assert!(ProjectPath::new("\\\\server\\share").is_ok()); } #[test] fn project_path_accepts_wsl_mount() { assert!(ProjectPath::new("/mnt/c/code").is_ok()); } #[test] fn project_path_rejects_relative() { let err = ProjectPath::new("relative/path").unwrap_err(); assert!(matches!(err, DomainError::PathNotAbsolute { .. })); } #[test] fn project_path_rejects_empty() { let err = ProjectPath::new("").unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } // --------------------------------------------------------------------------- // Project (also exercises the Clock/IdGenerator port fakes for determinism) // --------------------------------------------------------------------------- #[test] fn project_valid_with_fixed_clock_and_seq_ids() { use domain::{ports::Clock, ports::IdGenerator, ProjectId}; let clock = FixedClock(1_700_000_000_000); let ids = AtomicSeqIdGenerator::new(); let id = ProjectId::from_uuid(ids.new_uuid()); let p = Project::new( id, "demo", ProjectPath::new("/srv/demo").unwrap(), RemoteRef::local(), clock.now_millis(), ) .unwrap(); assert_eq!(p.created_at, 1_700_000_000_000); assert_eq!(p.id.as_uuid(), Uuid::from_u128(1)); } #[test] fn project_rejects_empty_name() { let err = Project::new( domain::ProjectId::from_uuid(Uuid::nil()), " ", ProjectPath::new("/x").unwrap(), RemoteRef::local(), 0, ) .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } // --------------------------------------------------------------------------- // Agent invariants // --------------------------------------------------------------------------- #[test] fn agent_scratch_not_synchronized_is_ok() { let a = Agent::new( domain::AgentId::from_uuid(Uuid::from_u128(1)), "scratch", "agents/foo.md", profile_id(), AgentOrigin::Scratch, false, ); assert!(a.is_ok()); } #[test] fn agent_from_template_synchronized_is_ok() { let a = Agent::new( domain::AgentId::from_uuid(Uuid::from_u128(1)), "tpl", "agents/foo.md", profile_id(), AgentOrigin::FromTemplate { template_id: template_id(), synced_template_version: TemplateVersion::INITIAL, }, true, ); assert!(a.is_ok()); } #[test] fn agent_synchronized_without_template_is_rejected() { let err = Agent::new( domain::AgentId::from_uuid(Uuid::from_u128(1)), "bad", "agents/foo.md", profile_id(), AgentOrigin::Scratch, true, ) .unwrap_err(); assert_eq!(err, DomainError::SyncRequiresTemplate); } #[test] fn agent_rejects_absolute_context_path() { let err = Agent::new( domain::AgentId::from_uuid(Uuid::from_u128(1)), "x", "/etc/passwd", profile_id(), AgentOrigin::Scratch, false, ) .unwrap_err(); assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); } #[test] fn agent_rejects_dotdot_context_path() { let err = Agent::new( domain::AgentId::from_uuid(Uuid::from_u128(1)), "x", "agents/../../secret.md", profile_id(), AgentOrigin::Scratch, false, ) .unwrap_err(); assert!(matches!(err, DomainError::PathNotRelativeSafe { .. })); } // --------------------------------------------------------------------------- // AgentProfile: command non-empty // --------------------------------------------------------------------------- fn ci_stdin() -> ContextInjection { ContextInjection::stdin() } #[test] fn profile_valid() { let p = AgentProfile::new( profile_id(), "Claude", "claude", vec!["--yolo".into()], ci_stdin(), Some("claude --version".into()), "{projectRoot}", ); assert!(p.is_ok()); } #[test] fn profile_rejects_empty_command() { let err = AgentProfile::new( profile_id(), "Name", "", vec![], ci_stdin(), None, "{projectRoot}", ) .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command")); } #[test] fn profile_rejects_empty_name() { let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}") .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name")); } // --------------------------------------------------------------------------- // RemoteRef invariants // --------------------------------------------------------------------------- #[test] fn ssh_valid() { let r = RemoteRef::ssh("host", 22, "me", SshAuth::Agent, "/srv"); assert!(r.is_ok()); assert_eq!(r.unwrap().kind(), domain::RemoteKind::Ssh); } #[test] fn ssh_port_zero_rejected() { let err = RemoteRef::ssh("host", 0, "me", SshAuth::Agent, "/srv").unwrap_err(); assert!(matches!(err, DomainError::InvalidPort { port: 0 })); } #[test] fn ssh_max_port_accepted() { // 65535 is the upper bound of the 1..=65535 range; u16 prevents anything higher. assert!(RemoteRef::ssh("h", 65535, "u", SshAuth::Password, "/r").is_ok()); } #[test] fn ssh_rejects_empty_host_user_root() { assert!(RemoteRef::ssh("", 22, "u", SshAuth::Agent, "/r").is_err()); assert!(RemoteRef::ssh("h", 22, "", SshAuth::Agent, "/r").is_err()); assert!(RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "").is_err()); } #[test] fn wsl_valid() { assert!(RemoteRef::wsl("Ubuntu-22.04").is_ok()); } #[test] fn wsl_empty_distro_rejected() { let err = RemoteRef::wsl("").unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } // --------------------------------------------------------------------------- // PtySize invariants // --------------------------------------------------------------------------- #[test] fn pty_size_valid() { assert!(PtySize::new(24, 80).is_ok()); } #[test] fn pty_size_zero_rows_rejected() { assert!(matches!( PtySize::new(0, 80).unwrap_err(), DomainError::InvalidPtySize { rows: 0, cols: 80 } )); } #[test] fn pty_size_zero_cols_rejected() { assert!(matches!( PtySize::new(24, 0).unwrap_err(), DomainError::InvalidPtySize { rows: 24, cols: 0 } )); } // --------------------------------------------------------------------------- // AgentTemplate version monotonicity (via with_updated_content / next) // --------------------------------------------------------------------------- #[test] fn template_starts_at_initial() { let t = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap(); assert_eq!(t.version, TemplateVersion::INITIAL); assert_eq!(t.version.get(), 1); } #[test] fn template_update_bumps_version_monotonically() { let t0 = AgentTemplate::new(template_id(), "T", MarkdownDoc::new("a"), profile_id()).unwrap(); let t1 = t0.with_updated_content(MarkdownDoc::new("b")); let t2 = t1.with_updated_content(MarkdownDoc::new("c")); assert!(t1.version > t0.version); assert!(t2.version > t1.version); assert_eq!(t2.version.get(), 3); assert_eq!(t2.content_md.as_str(), "c"); // id and profile preserved. assert_eq!(t2.id, t0.id); assert_eq!(t2.default_profile_id, t0.default_profile_id); } #[test] fn template_version_next_increments() { assert_eq!(TemplateVersion(5).next(), TemplateVersion(6)); } #[test] fn template_rejects_empty_name() { let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } // --------------------------------------------------------------------------- // ManifestEntry / AgentManifest invariants // --------------------------------------------------------------------------- fn agent_id(n: u128) -> domain::AgentId { domain::AgentId::from_uuid(Uuid::from_u128(n)) } #[test] fn manifest_entry_synchronized_requires_template_metadata() { let err = ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None) .unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); // template id present but version missing → still rejected. let err = ManifestEntry::new( agent_id(1), "A", "agents/a.md", profile_id(), Some(template_id()), true, None, ) .unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); } #[test] fn manifest_entry_rejects_empty_name() { let err = ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None) .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } #[test] fn manifest_entry_synchronized_with_metadata_ok() { assert!(ManifestEntry::new( agent_id(1), "A", "agents/a.md", profile_id(), Some(template_id()), true, Some(TemplateVersion::INITIAL) ) .is_ok()); } #[test] fn manifest_entry_rejects_absolute_md_path() { assert!(matches!( ManifestEntry::new(agent_id(1), "A", "/abs.md", profile_id(), None, false, None) .unwrap_err(), DomainError::PathNotRelativeSafe { .. } )); } #[test] fn manifest_entry_agent_roundtrip() { // from_agent ∘ to_agent preserves a template-backed, synchronized agent. let agent = Agent::new( agent_id(9), "Backend", "agents/backend.md", profile_id(), AgentOrigin::FromTemplate { template_id: template_id(), synced_template_version: TemplateVersion(4), }, true, ) .unwrap(); let entry = ManifestEntry::from_agent(&agent); assert_eq!(entry.to_agent().unwrap(), agent); } #[test] fn manifest_rejects_duplicate_md_path() { let e1 = ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) .unwrap(); let e2 = ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None) .unwrap(); let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); } #[test] fn manifest_unique_md_paths_ok() { let e1 = ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) .unwrap(); let e2 = ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None) .unwrap(); assert!(AgentManifest::new(1, vec![e1, e2]).is_ok()); } // --------------------------------------------------------------------------- // Skill invariants (L12, ARCHITECTURE §14.2) // --------------------------------------------------------------------------- fn skill_id(n: u128) -> SkillId { SkillId::from_uuid(Uuid::from_u128(n)) } #[test] fn skill_valid_construction() { let s = Skill::new( skill_id(1), "code-review", MarkdownDoc::new("review the diff"), SkillScope::Global, ); assert!(s.is_ok()); } #[test] fn skill_rejects_empty_name() { let err = Skill::new( skill_id(1), "", MarkdownDoc::new("body"), SkillScope::Project, ) .unwrap_err(); assert_eq!(err, DomainError::EmptyField { field: "skill.name" }); } #[test] fn skill_rejects_empty_content() { let err = Skill::new(skill_id(1), "x", MarkdownDoc::new(""), SkillScope::Global).unwrap_err(); assert_eq!( err, DomainError::EmptyField { field: "skill.content_md" } ); } #[test] fn skill_with_content_revalidates() { let s = Skill::new(skill_id(1), "x", MarkdownDoc::new("a"), SkillScope::Global).unwrap(); assert!(s.with_content(MarkdownDoc::new("b")).is_ok()); assert!(s.with_content(MarkdownDoc::new("")).is_err()); } #[test] fn agent_assign_skill_is_idempotent() { let mut a = Agent::new( agent_id(1), "dev", "agents/dev.md", profile_id(), AgentOrigin::Scratch, false, ) .unwrap(); let r = SkillRef::new(skill_id(7), SkillScope::Global); assert!(a.assign_skill(r)); // first assignment assert!(!a.assign_skill(r)); // duplicate ignored assert_eq!(a.skills, vec![r]); } #[test] fn agent_unassign_skill() { let mut a = Agent::new( agent_id(1), "dev", "agents/dev.md", profile_id(), AgentOrigin::Scratch, false, ) .unwrap(); let r = SkillRef::new(skill_id(7), SkillScope::Project); a.assign_skill(r); assert!(a.unassign_skill(skill_id(7))); assert!(!a.unassign_skill(skill_id(7))); // already gone assert!(a.skills.is_empty()); } #[test] fn agent_with_skills_dedups() { let r = SkillRef::new(skill_id(7), SkillScope::Global); let a = Agent::new( agent_id(1), "dev", "agents/dev.md", profile_id(), AgentOrigin::Scratch, false, ) .unwrap() .with_skills(vec![r, r]); assert_eq!(a.skills, vec![r]); } #[test] fn manifest_entry_preserves_skills_through_agent_roundtrip() { let r = SkillRef::new(skill_id(7), SkillScope::Project); let agent = Agent::new( agent_id(1), "dev", "agents/dev.md", profile_id(), AgentOrigin::Scratch, false, ) .unwrap() .with_skills(vec![r]); let entry = ManifestEntry::from_agent(&agent); assert_eq!(entry.skills, vec![r]); assert_eq!(entry.to_agent().unwrap(), agent); }