//! L8 tests for the Git use cases with a faked [`GitPort`] (no real repo): //! pass-through to the port, event emission on state changes, and input //! validation (empty message, non-absolute root). use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ports::{ EventBus, EventStream, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, }; use domain::{ProjectId, ProjectPath}; use uuid::Uuid; use application::{ GitBranches, GitBranchesInput, GitCheckout, GitCheckoutInput, GitCommit, GitCommitInput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, }; /// A recording [`GitPort`] with canned return values. #[derive(Default)] struct FakeGitInner { calls: Vec, status: Vec, branches: Vec, current: Option, } #[derive(Default, Clone)] struct FakeGit(Arc>); impl FakeGit { fn calls(&self) -> Vec { self.0.lock().unwrap().calls.clone() } fn set_status(&self, s: Vec) { self.0.lock().unwrap().status = s; } fn set_branches(&self, b: Vec, current: Option) { let mut i = self.0.lock().unwrap(); i.branches = b; i.current = current; } fn record(&self, c: &str) { self.0.lock().unwrap().calls.push(c.to_owned()); } } #[async_trait] impl GitPort for FakeGit { async fn init(&self, _r: &ProjectPath) -> Result<(), GitError> { self.record("init"); Ok(()) } async fn status(&self, _r: &ProjectPath) -> Result, GitError> { self.record("status"); Ok(self.0.lock().unwrap().status.clone()) } async fn stage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> { self.record(&format!("stage:{path}")); Ok(()) } async fn unstage(&self, _r: &ProjectPath, path: &str) -> Result<(), GitError> { self.record(&format!("unstage:{path}")); Ok(()) } async fn commit(&self, _r: &ProjectPath, message: &str) -> Result { self.record(&format!("commit:{message}")); Ok(GitCommitInfo { hash: "abc123".to_owned(), summary: message.to_owned(), }) } async fn branches(&self, _r: &ProjectPath) -> Result, GitError> { self.record("branches"); Ok(self.0.lock().unwrap().branches.clone()) } async fn current_branch(&self, _r: &ProjectPath) -> Result, GitError> { self.record("current_branch"); Ok(self.0.lock().unwrap().current.clone()) } async fn checkout(&self, _r: &ProjectPath, branch: &str) -> Result<(), GitError> { self.record(&format!("checkout:{branch}")); Ok(()) } async fn log( &self, _r: &ProjectPath, _limit: usize, ) -> Result, GitError> { self.record("log"); Ok(Vec::new()) } async fn log_graph( &self, _r: &ProjectPath, _limit: usize, ) -> Result, GitError> { self.record("log_graph"); Ok(Vec::new()) } async fn pull(&self, _r: &ProjectPath) -> Result<(), GitError> { Ok(()) } async fn push(&self, _r: &ProjectPath) -> Result<(), GitError> { Ok(()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } fn pid() -> ProjectId { ProjectId::from_uuid(Uuid::from_u128(1)) } const ROOT: &str = "/home/me/repo"; #[tokio::test] async fn status_passes_through_to_port() { let git = FakeGit::default(); git.set_status(vec![GitFileStatus { path: "a.txt".to_owned(), staged: true, }]); let out = GitStatus::new(Arc::new(git.clone())) .execute(GitStatusInput { root: ROOT.to_owned() }) .await .unwrap(); assert_eq!(out.entries.len(), 1); assert_eq!(out.entries[0].path, "a.txt"); assert_eq!(git.calls(), vec!["status"]); } #[tokio::test] async fn status_rejects_non_absolute_root() { let git = FakeGit::default(); let err = GitStatus::new(Arc::new(git.clone())) .execute(GitStatusInput { root: "relative/path".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "got {err:?}"); assert!(git.calls().is_empty(), "no port call on invalid root"); } #[tokio::test] async fn stage_calls_port_with_path() { let git = FakeGit::default(); GitStage::new(Arc::new(git.clone())) .execute(GitStagePathInput { root: ROOT.to_owned(), path: "src/x.rs".to_owned(), }) .await .unwrap(); assert_eq!(git.calls(), vec!["stage:src/x.rs"]); } #[tokio::test] async fn commit_returns_commit_and_publishes_event() { let git = FakeGit::default(); let bus = SpyBus::default(); let out = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone())) .execute(GitCommitInput { project_id: pid(), root: ROOT.to_owned(), message: "feat: x".to_owned(), }) .await .unwrap(); assert_eq!(out.commit.hash, "abc123"); assert_eq!(out.commit.summary, "feat: x"); assert_eq!(git.calls(), vec!["commit:feat: x"]); assert_eq!( bus.events(), vec![DomainEvent::GitStateChanged { project_id: pid() }] ); } #[tokio::test] async fn commit_rejects_empty_message_without_touching_port() { let git = FakeGit::default(); let bus = SpyBus::default(); let err = GitCommit::new(Arc::new(git.clone()), Arc::new(bus.clone())) .execute(GitCommitInput { project_id: pid(), root: ROOT.to_owned(), message: " ".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "got {err:?}"); assert!(git.calls().is_empty(), "no commit attempted"); assert!(bus.events().is_empty(), "no event on rejected commit"); } #[tokio::test] async fn checkout_publishes_event() { let git = FakeGit::default(); let bus = SpyBus::default(); GitCheckout::new(Arc::new(git.clone()), Arc::new(bus.clone())) .execute(GitCheckoutInput { project_id: pid(), root: ROOT.to_owned(), branch: "dev".to_owned(), }) .await .unwrap(); assert_eq!(git.calls(), vec!["checkout:dev"]); assert_eq!( bus.events(), vec![DomainEvent::GitStateChanged { project_id: pid() }] ); } #[tokio::test] async fn branches_returns_list_and_current() { let git = FakeGit::default(); git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned())); let out = GitBranches::new(Arc::new(git.clone())) .execute(GitBranchesInput { root: ROOT.to_owned() }) .await .unwrap(); assert_eq!(out.branches, vec!["main", "dev"]); assert_eq!(out.current.as_deref(), Some("main")); assert_eq!(git.calls(), vec!["branches", "current_branch"]); }