feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
411
crates/domain/tests/entities.rs
Normal file
411
crates/domain/tests/entities.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! 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());
|
||||
}
|
||||
Reference in New Issue
Block a user