Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
412 lines
12 KiB
Rust
412 lines
12 KiB
Rust
//! 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, 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());
|
|
}
|