diff --git a/crates/miyabi-core/src/lock.rs b/crates/miyabi-core/src/lock.rs index cb69369..8ed58fd 100644 --- a/crates/miyabi-core/src/lock.rs +++ b/crates/miyabi-core/src/lock.rs @@ -419,4 +419,79 @@ mod tests { let snapshot = snapshot_store.load().unwrap(); assert!(snapshot.get_task("task-a").unwrap().lock.is_none()); } + + #[test] + fn acquire_unknown_task_returns_error() { + let (_tmp, _events, _snapshot_store, manager) = fixture(); + let err = manager + .acquire_lock("nonexistent", "codex", "macbook", &[String::from("a.rs")]) + .unwrap_err(); + assert!(matches!(err, Error::Validation(_))); + } + + #[test] + fn renew_wrong_owner_returns_permission_denied() { + let (_tmp, _events, snapshot_store, manager) = fixture(); + seed_task(&snapshot_store, "task-a"); + manager + .acquire_lock("task-a", "codex", "macbook", &[String::from("a.rs")]) + .unwrap(); + let err = manager + .renew_lease("task-a", "other-agent", "other-node") + .unwrap_err(); + assert!(matches!(err, Error::PermissionDenied(_))); + } + + #[test] + fn release_without_lock_is_noop() { + let (_tmp, _events, snapshot_store, manager) = fixture(); + seed_task(&snapshot_store, "task-a"); + // release on a task with no lock should succeed (noop) + manager.release_lock("task-a").unwrap(); + } + + #[test] + fn has_conflict_no_locks_returns_false() { + let (_tmp, _events, _snapshot_store, manager) = fixture(); + let conflict = manager + .has_conflict(&[String::from("src/main.rs")]) + .unwrap(); + assert!(!conflict.conflicting); + assert!(conflict.held_by.is_none()); + assert!(conflict.task_id.is_none()); + } + + #[test] + fn acquire_multiple_files_locks_all() { + let (_tmp, _events, snapshot_store, manager) = fixture(); + seed_task(&snapshot_store, "task-a"); + let files = vec![ + String::from("a.rs"), + String::from("b.rs"), + String::from("c.rs"), + ]; + manager + .acquire_lock("task-a", "codex", "macbook", &files) + .unwrap(); + + for file in &files { + let conflict = manager.has_conflict(&[file.clone()]).unwrap(); + assert!(conflict.conflicting); + } + + manager.release_lock("task-a").unwrap(); + for file in &files { + let conflict = manager.has_conflict(&[file.clone()]).unwrap(); + assert!(!conflict.conflicting); + } + } + + #[test] + fn release_expired_with_no_locks_returns_empty() { + let (_tmp, _events, snapshot_store, manager) = fixture(); + seed_task(&snapshot_store, "task-a"); + let sweep = manager.release_expired_leases().unwrap(); + assert!(sweep.released.is_empty()); + assert!(sweep.active.is_empty()); + } } diff --git a/crates/miyabi-core/src/store.rs b/crates/miyabi-core/src/store.rs index eec062b..ec611f8 100644 --- a/crates/miyabi-core/src/store.rs +++ b/crates/miyabi-core/src/store.rs @@ -864,4 +864,209 @@ mod tests { let snapshot = snapshot_store.load().unwrap(); assert_eq!(snapshot.tasks[0].context_attachments.len(), 1); } + + #[test] + fn event_store_replay_since_id() { + let tmp = TempDir::new().unwrap(); + let store = EventStore::new(tmp.path().join("events.jsonl")); + + for i in 1..=5 { + store + .append(&TaskEvent { + id: format!("evt-{i}"), + ts: Utc::now(), + event_type: TaskEventType::GatePassed, + task_id: "task-a".into(), + agent: "codex".into(), + node: "macbook".into(), + payload: serde_json::json!({}), + version: i, + }) + .unwrap(); + } + + // replay since evt-3 should return evt-4 and evt-5 + let events = store.replay(Some("evt-3")).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].id, "evt-4"); + assert_eq!(events[1].id, "evt-5"); + } + + #[test] + fn event_store_replay_empty_file() { + let tmp = TempDir::new().unwrap(); + let store = EventStore::new(tmp.path().join("nonexistent.jsonl")); + let events = store.replay(None).unwrap(); + assert!(events.is_empty()); + } + + #[test] + fn snapshot_upsert_replaces_existing() { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new( + tmp.path().join("tasks.snapshot.json"), + tmp.path().join(".tasks.lock"), + ); + + let mut snapshot = TasksSnapshot::default(); + snapshot.upsert_task(sample_task("task-a")); + store.save(&snapshot, 0).unwrap(); + + let mut snapshot = store.load().unwrap(); + let mut updated = sample_task("task-a"); + updated.title = "Updated Title".into(); + updated.current_state = TaskState::Implementing; + snapshot.upsert_task(updated); + let version = snapshot.version; + store.save(&snapshot, version).unwrap(); + + let final_snapshot = store.load().unwrap(); + assert_eq!(final_snapshot.tasks.len(), 1); + assert_eq!(final_snapshot.tasks[0].title, "Updated Title"); + assert_eq!( + final_snapshot.tasks[0].current_state, + TaskState::Implementing + ); + } + + #[test] + fn snapshot_remove_task() { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new( + tmp.path().join("tasks.snapshot.json"), + tmp.path().join(".tasks.lock"), + ); + + let mut snapshot = TasksSnapshot::default(); + snapshot.upsert_task(sample_task("task-a")); + snapshot.upsert_task(sample_task("task-b")); + store.save(&snapshot, 0).unwrap(); + + let mut snapshot = store.load().unwrap(); + let removed = snapshot.remove_task("task-a"); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().id, "task-a"); + assert_eq!(snapshot.tasks.len(), 1); + assert_eq!(snapshot.tasks[0].id, "task-b"); + + // removing nonexistent returns None + assert!(snapshot.remove_task("task-z").is_none()); + } + + #[test] + fn legacy_task_file_loads_as_snapshot() { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new( + tmp.path().join("tasks.snapshot.json"), + tmp.path().join(".tasks.lock"), + ); + + let legacy_json = serde_json::json!({ + "version": 5, + "tasks": [ + { + "id": "task-legacy", + "title": "Legacy Task", + "state": "implementing", + "dependencies": ["dep-1"], + "dependents": [], + "soft_dependencies": [], + "lock": { + "locked_by": "agent@node", + "locked_at": "2026-01-01T00:00:00Z", + "ttl_secs": 300, + "affected_files": ["src/main.rs"] + }, + "impact": { + "risk_level": "HIGH", + "affected_symbols": 10, + "depth1": ["fn_a"], + "analyzed_at": "2026-01-01T00:00:00Z" + }, + "branch_name": "feature/legacy", + "pr_number": 42, + "merge_commit": null + } + ] + }); + fs::write( + store.path(), + serde_json::to_vec_pretty(&legacy_json).unwrap(), + ) + .unwrap(); + + let snapshot = store.load().unwrap(); + assert_eq!(snapshot.tasks.len(), 1); + let task = &snapshot.tasks[0]; + assert_eq!(task.id, "task-legacy"); + assert_eq!(task.current_state, TaskState::Implementing); + assert!(task.lock.is_some()); + assert!(task.impact.is_some()); + assert_eq!(task.impact.as_ref().unwrap().risk_level, ImpactRiskLevel::High); + assert!(task.github_evidence.is_some()); + assert_eq!(task.github_evidence.as_ref().unwrap().pr_number, 42); + assert_eq!(task.github_evidence.as_ref().unwrap().pr_state, GitHubPrState::Open); + // file_locks should be populated from legacy lock + assert!(snapshot.file_locks.contains_key("src/main.rs")); + } + + #[test] + fn apply_lock_released_event_clears_locks() { + let tmp = TempDir::new().unwrap(); + let event_store = EventStore::new(tmp.path().join("events.jsonl")); + let snapshot_store = SnapshotStore::new( + tmp.path().join("tasks.snapshot.json"), + tmp.path().join(".tasks.lock"), + ); + + let mut snapshot = TasksSnapshot::default(); + snapshot.upsert_task(sample_task("task-a")); + snapshot_store.save(&snapshot, 0).unwrap(); + + let now = Utc::now(); + // acquire + event_store + .append(&TaskEvent { + id: "1".into(), + ts: now, + event_type: TaskEventType::LockAcquired, + task_id: "task-a".into(), + agent: "codex".into(), + node: "macbook".into(), + payload: serde_json::json!({ + "files": ["src/lib.rs", "src/main.rs"], + "expires_at": now + Duration::seconds(300), + "lease_duration_sec": 300 + }), + version: 1, + }) + .unwrap(); + // release + event_store + .append(&TaskEvent { + id: "2".into(), + ts: now + Duration::seconds(10), + event_type: TaskEventType::LockReleased, + task_id: "task-a".into(), + agent: "system".into(), + node: "system".into(), + payload: serde_json::json!({}), + version: 2, + }) + .unwrap(); + + let rebuilt = snapshot_store.rebuild(&event_store).unwrap(); + assert!(rebuilt.get_task("task-a").unwrap().lock.is_none()); + assert!(rebuilt.file_locks.is_empty()); + } + + #[test] + fn lease_expiry_calculates_correctly() { + let base = Utc::now(); + let result = lease_expiry(base, 300); + assert_eq!(result, base + Duration::seconds(300)); + + let result_zero = lease_expiry(base, 0); + assert_eq!(result_zero, base); + } }