1use std::sync::{Arc, Weak};
11use std::time::SystemTime;
12
13use forge_primitives::tasks::{
14 By, Estimate, LinkKind, Task, TaskId, TaskLink, TaskStatus, WaitingKind,
15};
16
17use crate::SessionSlot;
18use crate::mcp::caller_context::caller_context;
19use crate::tasks::{ClaimError, IdError, MoveError};
20use crate::workspace::Workspace;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub(crate) enum TasksError {
25 UnknownCallerProject,
28 BadEstimate(String),
31 Refused(String),
33 NoSuchTask { id: String },
35 TaskInAnotherProject { id: String, project: String },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
44pub(crate) struct TaskLinkDraft {
45 #[serde(default)]
46 pub kind: Option<LinkKind>,
47 #[serde(default)]
48 pub label: Option<String>,
49 pub target: String,
50}
51
52impl TaskLinkDraft {
53 fn to_link(&self, at: SystemTime) -> TaskLink {
54 TaskLink {
55 kind: self.kind.unwrap_or_else(|| LinkKind::for_target(&self.target)),
56 label: self.label.clone(),
57 target: self.target.clone(),
58 state: None,
59 added_at: at,
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
67pub(crate) struct TaskDraft {
68 pub subject: String,
69 #[serde(default)]
70 pub active_form: Option<String>,
71 #[serde(default)]
72 pub detail: Option<String>,
73 #[serde(default)]
74 pub status: Option<TaskStatus>,
75 #[serde(default)]
78 pub owner: Option<String>,
79 #[serde(default)]
80 pub parent: Option<String>,
81 #[serde(default)]
82 pub estimate: Option<String>,
83 #[serde(default)]
85 pub rank: Option<i64>,
86 #[serde(default)]
88 pub verify: Option<forge_primitives::tasks::Verify>,
89 #[serde(default)]
91 pub reserved: Option<forge_primitives::tasks::ReservedBy>,
92 #[serde(default)]
93 pub links: Vec<TaskLinkDraft>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
99pub(crate) struct RemovedTaskTree {
100 pub task: Task,
101 pub descendants_removed: usize,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
106pub(crate) struct TaskPatch {
107 #[serde(default)]
108 pub subject: Option<String>,
109 #[serde(default)]
110 pub active_form: Option<String>,
111 #[serde(default)]
112 pub detail: Option<String>,
113 #[serde(default)]
114 pub status: Option<TaskStatus>,
115 #[serde(default)]
116 pub owner: Option<String>,
117 #[serde(default)]
118 pub parent: Option<String>,
119 #[serde(default)]
120 pub estimate: Option<String>,
121 #[serde(default)]
122 pub rank: Option<i64>,
123 #[serde(default)]
124 pub verify: Option<forge_primitives::tasks::Verify>,
125 #[serde(default)]
128 pub parked: Option<bool>,
129 #[serde(default)]
132 pub links_add: Vec<TaskLinkDraft>,
133 #[serde(default)]
135 pub links_remove: Vec<String>,
136}
137
138impl TaskPatch {
139 fn apply(
144 &self,
145 task: &mut Task,
146 org: &str,
147 project: &str,
148 estimate: Option<Estimate>,
149 at: SystemTime,
150 ) {
151 if let Some(subject) = &self.subject {
152 task.subject.clone_from(subject);
153 }
154 if let Some(active_form) = &self.active_form {
155 task.active_form = Some(active_form.clone());
156 }
157 if let Some(detail) = &self.detail {
158 task.detail = Some(detail.clone());
159 }
160 if let Some(status) = self.status {
161 task.status = status;
162 }
163 if let Some(owner) = &self.owner {
164 task.owner = Some(SessionSlot::new(org, project, owner.clone()));
165 }
166 if let Some(parent) = &self.parent {
167 task.parent = Some(TaskId::from(parent.as_str()));
168 }
169 if let Some(rank) = self.rank {
170 task.rank = Some(rank);
171 }
172 if let Some(verify) = self.verify {
173 task.verify = Some(verify);
174 }
175 if let Some(parked) = self.parked
176 && let Some(wait) = task.waiting_on.as_mut()
177 {
178 wait.parked = parked;
179 }
180 for draft in &self.links_add {
181 if !task.links.iter().any(|l| l.target == draft.target) {
182 task.links.push(draft.to_link(at));
183 }
184 }
185 task.links.retain(|l| !self.links_remove.contains(&l.target));
186 if let Some(estimate) = estimate {
187 task.estimate = Some(estimate);
188 }
189 }
190}
191
192fn missing_task_error(ws: &Workspace, project_name: &str, id: &TaskId) -> TasksError {
196 let stated = id.as_str().to_owned();
197 match ws.task_project_outside(project_name, id) {
198 Some(project) => TasksError::TaskInAnotherProject { id: stated, project },
199 None => TasksError::NoSuchTask { id: stated },
200 }
201}
202
203fn resolve_parent(
207 ws: &Workspace,
208 project_name: &str,
209 stated: Option<&str>,
210) -> Result<Option<TaskId>, TasksError> {
211 let Some(stated) = stated else { return Ok(None) };
212 match ws.resolve_task_id(project_name, &TaskId::from(stated)) {
213 Ok(id) => Ok(Some(id)),
214 Err(IdError::NotFound) => Ok(Some(TaskId::from(stated))),
215 Err(error @ IdError::Ambiguous(_)) => Err(TasksError::Refused(error.to_string())),
216 }
217}
218
219fn narrow_rows(
223 rows: Vec<crate::board::BoardRow>,
224 owner: Option<&str>,
225 parent: Option<&TaskId>,
226 status: Option<TaskStatus>,
227 ready: Option<bool>,
228) -> Vec<crate::board::BoardRow> {
229 rows.into_iter()
230 .filter(|row| {
231 owner.is_none_or(|label| row.task.owner.as_ref().is_some_and(|o| o.label() == label))
232 })
233 .filter(|row| parent.is_none_or(|id| row.task.parent.as_ref() == Some(id)))
234 .filter(|row| status.is_none_or(|wanted| row.task.status == wanted))
235 .filter(|row| ready.is_none_or(|wanted| row.marks.ready == wanted))
236 .collect()
237}
238
239pub(crate) trait TasksFacade: Send + Sync {
242 fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError>;
245
246 fn list_tasks(
249 &self,
250 caller: &SessionSlot,
251 owner: Option<&str>,
252 parent: Option<&TaskId>,
253 status: Option<TaskStatus>,
254 ready: Option<bool>,
255 ) -> Vec<crate::board::BoardRow>;
256
257 fn update_task(
263 &self,
264 caller: &SessionSlot,
265 id: &TaskId,
266 patch: TaskPatch,
267 ) -> Result<Task, TasksError>;
268
269 fn delete_task(&self, caller: &SessionSlot, id: &TaskId)
274 -> Result<RemovedTaskTree, TasksError>;
275
276 fn claim_task(
279 &self,
280 caller: &SessionSlot,
281 id: Option<&str>,
282 epic: Option<&str>,
283 ) -> Result<Task, TasksError>;
284
285 fn wait_task(
287 &self,
288 caller: &SessionSlot,
289 id: &TaskId,
290 kind: WaitingKind,
291 detail: Option<String>,
292 on: Option<&TaskId>,
293 ) -> Result<Task, TasksError>;
294
295 fn attest_task(
298 &self,
299 caller: &SessionSlot,
300 id: &TaskId,
301 approval: &str,
302 ) -> Result<Task, TasksError>;
303}
304
305pub(crate) struct ProdTasksFacade {
308 workspace: Weak<Workspace>,
309}
310
311impl ProdTasksFacade {
312 pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn TasksFacade> {
313 Arc::new(Self { workspace: Arc::downgrade(workspace) })
314 }
315}
316
317impl TasksFacade for ProdTasksFacade {
318 fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError> {
319 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
320 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
321 let now = SystemTime::now();
322 let estimate = match draft.estimate.as_deref() {
323 Some(words) => Some(
324 Estimate::parse(words).ok_or_else(|| TasksError::BadEstimate(words.to_owned()))?,
325 ),
326 None => None,
327 };
328 let links = draft.links.iter().map(|draft| draft.to_link(now)).collect();
329 let task = Task {
330 id: TaskId::from(uuid::Uuid::new_v4().to_string()),
331 project_name: cx.project_name.clone(),
332 subject: draft.subject,
333 active_form: draft.active_form,
334 detail: draft.detail,
335 status: draft.status.unwrap_or(TaskStatus::Pending),
336 owner: draft
337 .owner
338 .map(|label| SessionSlot::new(&cx.project_org, &cx.project_name, label)),
339 parent: resolve_parent(&ws, &cx.project_name, draft.parent.as_deref())?,
340 waiting_on: None,
341 estimate,
342 rank: draft.rank,
343 verify: draft.verify,
344 attested: None,
345 reserved: draft.reserved,
346 links,
347 attempt: 0,
348 archived_at: None,
349 created_at: now,
350 updated_at: now,
351 };
352 ws.push_task(task.clone());
353 Ok(task)
354 }
355
356 fn list_tasks(
357 &self,
358 caller: &SessionSlot,
359 owner: Option<&str>,
360 parent: Option<&TaskId>,
361 status: Option<TaskStatus>,
362 ready: Option<bool>,
363 ) -> Vec<crate::board::BoardRow> {
364 let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
365 let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
366 let rows =
367 ws.board_rows(&cx.project_name, SystemTime::now(), crate::board::DEFAULT_STALE_SECS);
368 let parent = parent.map(|stated| {
372 ws.resolve_task_id(&cx.project_name, stated).unwrap_or_else(|_| stated.clone())
373 });
374 narrow_rows(rows, owner, parent.as_ref(), status, ready)
375 }
376
377 fn update_task(
378 &self,
379 caller: &SessionSlot,
380 id: &TaskId,
381 patch: TaskPatch,
382 ) -> Result<Task, TasksError> {
383 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
384 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
385 let estimate = match patch.estimate.as_deref() {
386 Some(words) => Some(
387 Estimate::parse(words).ok_or_else(|| TasksError::BadEstimate(words.to_owned()))?,
388 ),
389 None => None,
390 };
391 let mut patch = patch;
392 if let Some(parent) = resolve_parent(&ws, &cx.project_name, patch.parent.as_deref())? {
393 patch.parent = Some(parent.as_str().to_owned());
394 }
395 let at = SystemTime::now();
396 ws.update_task(&cx.project_name, id, By::Seat(caller.clone()), |task| {
397 patch.apply(task, &cx.project_org, &cx.project_name, estimate, at);
398 })
399 .map_err(|refused| TasksError::Refused(refused.to_string()))?
400 .ok_or_else(|| missing_task_error(&ws, &cx.project_name, id))
401 }
402
403 fn delete_task(
404 &self,
405 caller: &SessionSlot,
406 id: &TaskId,
407 ) -> Result<RemovedTaskTree, TasksError> {
408 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
409 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
410 let resolved = match ws.resolve_task_id(&cx.project_name, id) {
414 Ok(resolved) => resolved,
415 Err(IdError::NotFound) => return Err(missing_task_error(&ws, &cx.project_name, id)),
416 Err(error @ IdError::Ambiguous(_)) => {
417 return Err(TasksError::Refused(error.to_string()));
418 }
419 };
420 let removed = ws
421 .remove_task_tree(&cx.project_name, &resolved)
422 .map_err(|refused| TasksError::Refused(refused.to_string()))?;
423 let Some(task) = removed.iter().find(|t| t.id == resolved).cloned() else {
426 return Err(missing_task_error(&ws, &cx.project_name, id));
427 };
428 Ok(RemovedTaskTree { task, descendants_removed: removed.len() - 1 })
429 }
430
431 fn claim_task(
432 &self,
433 caller: &SessionSlot,
434 id: Option<&str>,
435 epic: Option<&str>,
436 ) -> Result<Task, TasksError> {
437 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
438 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
439 let by_id = id.map(TaskId::from);
440 let in_epic = epic.map(TaskId::from);
441 match ws.claim_task(&cx.project_name, caller, by_id.as_ref(), in_epic.as_ref()) {
442 Ok(task) => Ok(task),
443 Err(ClaimError::NotFound) => {
444 let stated = by_id.as_ref().or(in_epic.as_ref());
447 Err(match stated {
448 Some(stated) => missing_task_error(&ws, &cx.project_name, stated),
449 None => TasksError::Refused(ClaimError::NotFound.to_string()),
450 })
451 }
452 Err(refused) => Err(TasksError::Refused(refused.to_string())),
453 }
454 }
455
456 fn wait_task(
457 &self,
458 caller: &SessionSlot,
459 id: &TaskId,
460 kind: WaitingKind,
461 detail: Option<String>,
462 on: Option<&TaskId>,
463 ) -> Result<Task, TasksError> {
464 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
465 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
466 match ws.wait_task(&cx.project_name, id, caller, kind, detail, on) {
467 Ok(task) => Ok(task),
468 Err(MoveError::NotFound) => {
469 let stated = match ws.resolve_task_id(&cx.project_name, id) {
472 Ok(_) => on,
473 Err(_) => Some(id),
474 };
475 Err(match stated {
476 Some(stated) => missing_task_error(&ws, &cx.project_name, stated),
477 None => TasksError::Refused(MoveError::NotFound.to_string()),
478 })
479 }
480 Err(refused) => Err(TasksError::Refused(refused.to_string())),
481 }
482 }
483
484 fn attest_task(
485 &self,
486 caller: &SessionSlot,
487 id: &TaskId,
488 approval: &str,
489 ) -> Result<Task, TasksError> {
490 let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
491 let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
492 match ws.attest_task(&cx.project_name, id, caller, approval) {
493 Ok(Some(task)) => Ok(task),
494 Ok(None) => Err(missing_task_error(&ws, &cx.project_name, id)),
495 Err(refused) => Err(TasksError::Refused(refused.to_string())),
496 }
497 }
498}
499
500#[cfg(test)]
502type CreateCall = (SessionSlot, TaskDraft, Task);
503
504#[cfg(test)]
506fn mock_task(caller: &SessionSlot) -> Task {
507 let now = SystemTime::UNIX_EPOCH;
508 Task {
509 id: TaskId::from("mock-task-id"),
510 project_name: caller.project().to_owned(),
511 subject: "mock".to_owned(),
512 active_form: None,
513 detail: None,
514 status: TaskStatus::Pending,
515 owner: None,
516 parent: None,
517 waiting_on: None,
518 estimate: None,
519 rank: None,
520 verify: None,
521 attested: None,
522 reserved: None,
523 links: Vec::new(),
524 attempt: 0,
525 archived_at: None,
526 created_at: now,
527 updated_at: now,
528 }
529}
530
531#[cfg(test)]
533type ListCall = (Option<String>, Option<String>, Option<TaskStatus>, Option<bool>);
534
535#[cfg(test)]
537type ClaimCall = (SessionSlot, Option<String>, Option<String>);
538
539#[cfg(test)]
541type WaitCall = (SessionSlot, TaskId, WaitingKind, Option<String>, Option<TaskId>);
542
543#[cfg(test)]
547#[derive(Default)]
548pub(crate) struct MockTasksFacade {
549 pub created: parking_lot::Mutex<Vec<CreateCall>>,
550 pub rows: parking_lot::Mutex<Vec<crate::board::BoardRow>>,
551 pub listed: parking_lot::Mutex<Vec<ListCall>>,
552 pub updated: parking_lot::Mutex<Vec<(SessionSlot, TaskId, TaskPatch)>>,
553 pub update_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
554 pub deleted: parking_lot::Mutex<Vec<(SessionSlot, TaskId)>>,
555 pub delete_result: parking_lot::Mutex<Option<Result<RemovedTaskTree, TasksError>>>,
556 pub claimed: parking_lot::Mutex<Vec<ClaimCall>>,
557 pub claim_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
558 pub waited: parking_lot::Mutex<Vec<WaitCall>>,
559 pub wait_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
560 pub attested: parking_lot::Mutex<Vec<(SessionSlot, TaskId, String)>>,
561}
562
563#[cfg(test)]
564impl MockTasksFacade {
565 pub(crate) fn new() -> Self {
566 Self::default()
567 }
568 pub(crate) fn into_arc(self) -> Arc<dyn TasksFacade> {
569 Arc::new(self)
570 }
571}
572
573#[cfg(test)]
574impl TasksFacade for MockTasksFacade {
575 fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError> {
576 let now = SystemTime::UNIX_EPOCH;
577 let task = Task {
578 id: TaskId::from("mock-task-id"),
579 project_name: caller.project().to_owned(),
580 subject: draft.subject.clone(),
581 active_form: draft.active_form.clone(),
582 detail: draft.detail.clone(),
583 status: draft.status.unwrap_or(TaskStatus::Pending),
584 owner: draft
585 .owner
586 .clone()
587 .map(|label| SessionSlot::new(caller.org(), caller.project(), label)),
588 parent: draft.parent.as_deref().map(TaskId::from),
592 waiting_on: None,
593 estimate: draft.estimate.as_deref().and_then(Estimate::parse),
594 rank: draft.rank,
595 verify: draft.verify,
596 attested: None,
597 reserved: draft.reserved,
598 links: draft.links.iter().map(|draft| draft.to_link(now)).collect(),
599 attempt: 0,
600 archived_at: None,
601 created_at: now,
602 updated_at: now,
603 };
604 self.created.lock().push((caller.clone(), draft, task.clone()));
605 Ok(task)
606 }
607
608 fn claim_task(
609 &self,
610 caller: &SessionSlot,
611 id: Option<&str>,
612 epic: Option<&str>,
613 ) -> Result<Task, TasksError> {
614 self.claimed.lock().push((caller.clone(), id.map(str::to_owned), epic.map(str::to_owned)));
615 self.claim_result.lock().clone().unwrap_or_else(|| Ok(mock_task(caller)))
616 }
617
618 fn wait_task(
619 &self,
620 caller: &SessionSlot,
621 id: &TaskId,
622 kind: WaitingKind,
623 detail: Option<String>,
624 on: Option<&TaskId>,
625 ) -> Result<Task, TasksError> {
626 self.waited.lock().push((caller.clone(), id.clone(), kind, detail, on.cloned()));
627 self.wait_result.lock().clone().unwrap_or_else(|| Ok(mock_task(caller)))
628 }
629
630 fn attest_task(
631 &self,
632 caller: &SessionSlot,
633 id: &TaskId,
634 approval: &str,
635 ) -> Result<Task, TasksError> {
636 self.attested.lock().push((caller.clone(), id.clone(), approval.to_owned()));
637 let mut task = mock_task(caller);
638 task.attested = Some(forge_primitives::tasks::Attestation {
639 evidence: approval.to_owned(),
640 at: SystemTime::UNIX_EPOCH,
641 });
642 Ok(task)
643 }
644
645 fn list_tasks(
646 &self,
647 _caller: &SessionSlot,
648 owner: Option<&str>,
649 parent: Option<&TaskId>,
650 status: Option<TaskStatus>,
651 ready: Option<bool>,
652 ) -> Vec<crate::board::BoardRow> {
653 self.listed.lock().push((
654 owner.map(str::to_owned),
655 parent.map(|id| id.as_str().to_owned()),
656 status,
657 ready,
658 ));
659 narrow_rows(self.rows.lock().clone(), owner, parent, status, ready)
660 }
661
662 fn update_task(
663 &self,
664 caller: &SessionSlot,
665 id: &TaskId,
666 patch: TaskPatch,
667 ) -> Result<Task, TasksError> {
668 self.updated.lock().push((caller.clone(), id.clone(), patch));
669 self.update_result
670 .lock()
671 .clone()
672 .unwrap_or_else(|| Err(TasksError::NoSuchTask { id: id.as_str().to_owned() }))
673 }
674
675 fn delete_task(
676 &self,
677 caller: &SessionSlot,
678 id: &TaskId,
679 ) -> Result<RemovedTaskTree, TasksError> {
680 self.deleted.lock().push((caller.clone(), id.clone()));
681 self.delete_result
682 .lock()
683 .clone()
684 .unwrap_or_else(|| Err(TasksError::NoSuchTask { id: id.as_str().to_owned() }))
685 }
686}
687
688#[cfg(test)]
691mod prod_facade_tests {
692 use super::*;
693 use crate::WorkerEntry;
694 use forge_primitives::WorkerLiveness;
695
696 fn worker_entry(project: &str, label: &str) -> WorkerEntry {
697 WorkerEntry {
698 label: label.to_owned(),
699 charter: "review".to_owned(),
700 slot: SessionSlot::worker("TestOrg", project, label),
701 session_id: None,
702 status: WorkerLiveness::Running,
703 spawned_at: SystemTime::UNIX_EPOCH,
704 spawned_by: SessionSlot::lead("TestOrg", project),
705 needs_tag: false,
706 is_git_repo_at_spawn: false,
707 cwd: None,
708 diagnostic: None,
709 kick: None,
710 }
711 }
712
713 fn seeded_task(id: &str, subject: &str) -> Task {
714 Task {
715 id: TaskId::from(id),
716 project_name: "myproj".to_owned(),
717 subject: subject.to_owned(),
718 active_form: None,
719 detail: None,
720 status: TaskStatus::Pending,
721 owner: None,
722 parent: None,
723 waiting_on: None,
724 estimate: None,
725 rank: None,
726 verify: None,
727 attested: None,
728 reserved: None,
729 links: Vec::new(),
730 attempt: 0,
731 archived_at: None,
732 created_at: SystemTime::UNIX_EPOCH,
733 updated_at: SystemTime::UNIX_EPOCH,
734 }
735 }
736
737 fn draft(subject: &str, owner: Option<&str>, parent: Option<&str>) -> TaskDraft {
738 TaskDraft {
739 subject: subject.to_owned(),
740 active_form: None,
741 detail: None,
742 status: None,
743 owner: owner.map(str::to_owned),
744 parent: parent.map(str::to_owned),
745 estimate: None,
746 rank: None,
747 verify: None,
748 reserved: None,
749 links: Vec::new(),
750 }
751 }
752
753 fn fixture() -> (Arc<Workspace>, Arc<dyn TasksFacade>, SessionSlot, SessionSlot) {
754 let (ws, _rx) = Workspace::testing_stub();
755 ws.seed_test_project("myproj", "/tmp/b2-myproj");
756 ws.seed_test_project("otherproj", "/tmp/b2-otherproj");
757 let key =
758 ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("seeded view").key;
759 ws.record_connected_session("/tmp/b2-myproj", "lead-uuid", None);
760 ws.insert_live_worker(&key, worker_entry("myproj", "reviewer"));
761 let facade = ProdTasksFacade::from_arc(&ws);
762 (
763 ws,
764 facade,
765 SessionSlot::lead("TestOrg", "myproj"),
766 SessionSlot::worker("TestOrg", "myproj", "reviewer"),
767 )
768 }
769
770 #[test]
771 fn a_draft_is_stamped_with_the_callers_project_and_id() {
772 let (_ws, facade, lead, _worker) = fixture();
773 let task = facade
774 .create_task(&lead, draft("Merge peers and workers", None, None))
775 .expect("create");
776 assert_eq!(task.project_name, "myproj", "stamped with the caller's project");
777 assert!(!task.id.as_str().is_empty(), "the facade mints the id");
778 assert_eq!(task.status, TaskStatus::Pending, "an unstated status is pending");
779 assert_eq!(task.owner, None, "an unstated owner is unclaimed");
780 assert_eq!(task.created_at, task.updated_at, "a fresh task's stamps agree");
781 }
782
783 #[test]
784 fn list_narrows_by_owner_and_parent_within_the_project() {
785 let (_ws, facade, lead, worker) = fixture();
786 let epic = facade.create_task(&lead, draft("epic", None, None)).expect("epic");
787 facade
788 .create_task(&lead, draft("mine", Some("reviewer"), Some(epic.id.as_str())))
789 .expect("child");
790 facade.create_task(&lead, draft("unclaimed", None, None)).expect("sibling");
791
792 assert_eq!(
793 facade.list_tasks(&lead, None, None, None, None).len(),
794 3,
795 "unfiltered is the whole project"
796 );
797 let by_owner = facade.list_tasks(&lead, Some("reviewer"), None, None, None);
798 assert_eq!(by_owner.len(), 1, "owner narrows to that label");
799 assert_eq!(by_owner[0].task.subject, "mine");
800 let by_parent = facade.list_tasks(&lead, None, Some(&epic.id), None, None);
801 assert_eq!(by_parent.len(), 1, "parent narrows to that task's children");
802 assert_eq!(by_parent[0].task.subject, "mine");
803 assert_eq!(
804 facade.list_tasks(&worker, Some("reviewer"), None, None, None).len(),
805 1,
806 "a worker's list is the same project's set",
807 );
808 }
809
810 #[test]
815 fn delete_reports_nothing_removed_for_an_id_that_was_never_there() {
816 let (_ws, facade, lead, _worker) = fixture();
817 facade
818 .create_task(&lead, draft("orphan", None, Some("ghost")))
819 .expect("a child pointing at a parent that never existed");
820 assert_eq!(
821 facade.delete_task(&lead, &TaskId::from("ghost")),
822 Err(TasksError::NoSuchTask { id: "ghost".to_owned() }),
823 "an id that was never there is not a successful delete, even with orphans pointing \
824 at it",
825 );
826 assert_eq!(
827 facade.list_tasks(&lead, None, None, None, None).len(),
828 1,
829 "the orphan is not collected under a name nothing owns",
830 );
831 }
832
833 #[test]
838 fn a_task_in_another_project_is_not_an_unknown_id() {
839 let (ws, facade, lead, _worker) = fixture();
840 let mut elsewhere = seeded_task("other-1", "another project's row");
841 elsewhere.project_name = "otherproj".to_owned();
842 ws.seed_test_task(elsewhere);
843
844 assert_eq!(
845 facade.update_task(
846 &lead,
847 &TaskId::from("other-1"),
848 TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
849 ),
850 Err(TasksError::TaskInAnotherProject {
851 id: "other-1".to_owned(),
852 project: "otherproj".to_owned(),
853 }),
854 "the update names the project that owns the row",
855 );
856 assert_eq!(
857 facade.delete_task(&lead, &TaskId::from("other-1")),
858 Err(TasksError::TaskInAnotherProject {
859 id: "other-1".to_owned(),
860 project: "otherproj".to_owned(),
861 }),
862 "the delete names the project that owns the row",
863 );
864 let rows = ws.tasks_for_project("otherproj");
865 assert_eq!(rows.len(), 1, "the other project's row is still there: {rows:?}");
866 assert_eq!(
867 rows[0].status,
868 TaskStatus::Pending,
869 "and neither refusal touched the other project's row",
870 );
871 }
872
873 #[test]
878 fn an_id_no_project_has_reads_as_absent() {
879 let (ws, facade, lead, _worker) = fixture();
880 let mut elsewhere = seeded_task("other-1", "another project's row");
881 elsewhere.project_name = "otherproj".to_owned();
882 ws.seed_test_task(elsewhere);
883
884 for stated in ["other", "nope"] {
885 assert_eq!(
886 facade.update_task(&lead, &TaskId::from(stated), TaskPatch::default()),
887 Err(TasksError::NoSuchTask { id: stated.to_owned() }),
888 "{stated} names no row in this project",
889 );
890 assert_eq!(
891 facade.delete_task(&lead, &TaskId::from(stated)),
892 Err(TasksError::NoSuchTask { id: stated.to_owned() }),
893 "{stated} names no row in this project",
894 );
895 }
896 }
897
898 #[test]
902 fn claim_and_wait_refusals_name_the_case() {
903 let (ws, facade, lead, _worker) = fixture();
904 let mut elsewhere = seeded_task("other-1", "another project's row");
905 elsewhere.project_name = "otherproj".to_owned();
906 ws.seed_test_task(elsewhere);
907 let own = facade
908 .create_task(&lead, draft("the waiting row", Some("lead"), None))
909 .expect("create");
910
911 let foreign = || TasksError::TaskInAnotherProject {
912 id: "other-1".to_owned(),
913 project: "otherproj".to_owned(),
914 };
915 assert_eq!(
916 facade.claim_task(&lead, Some("other-1"), None),
917 Err(foreign()),
918 "a claim naming another project's row says which project holds it",
919 );
920 assert_eq!(
921 facade.claim_task(&lead, None, Some("other-1")),
922 Err(foreign()),
923 "an epic naming another project's row says the same",
924 );
925 assert_eq!(
926 facade.wait_task(&lead, &TaskId::from("other-1"), WaitingKind::Resource, None, None),
927 Err(foreign()),
928 "the row a wait names is another project's",
929 );
930 assert_eq!(
931 facade.wait_task(
932 &lead,
933 &own.id,
934 WaitingKind::Dependency,
935 None,
936 Some(&TaskId::from("other-1")),
937 ),
938 Err(foreign()),
939 "the blocker a wait names is another project's",
940 );
941
942 assert_eq!(
943 facade.claim_task(&lead, Some("other"), None),
944 Err(TasksError::NoSuchTask { id: "other".to_owned() }),
945 "a prefix of another project's id names no row here",
946 );
947 assert_eq!(
948 facade.claim_task(&lead, Some("nope"), None),
949 Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
950 );
951 assert_eq!(
952 facade.wait_task(&lead, &TaskId::from("nope"), WaitingKind::Resource, None, None),
953 Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
954 "a missing row is absent, not another project's",
955 );
956 assert_eq!(
957 facade.wait_task(
958 &lead,
959 &own.id,
960 WaitingKind::Dependency,
961 None,
962 Some(&TaskId::from("nope")),
963 ),
964 Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
965 "a missing blocker is absent, not another project's",
966 );
967 }
968
969 #[test]
981 fn an_update_parks_a_wait_and_only_a_wait() {
982 let (ws, facade, lead, _worker) = fixture();
983 let mut waiting = seeded_task("waiting", "a deferral");
984 waiting.status = TaskStatus::Waiting;
985 waiting.waiting_on = Some(forge_primitives::tasks::Waiting {
986 kind: Some(forge_primitives::tasks::WaitingKind::Decision),
987 detail: None,
988 on: None,
989 verification: false,
990 parked: false,
991 since: None,
992 });
993 ws.seed_test_task(waiting);
994 ws.seed_test_task(seeded_task("plain", "a row with no wait"));
995
996 let parked = facade
997 .update_task(
998 &lead,
999 &TaskId::from("waiting"),
1000 TaskPatch { parked: Some(true), ..TaskPatch::default() },
1001 )
1002 .expect("the waiting row is there");
1003 assert!(
1004 parked.waiting_on.as_ref().is_some_and(|wait| wait.parked),
1005 "the wait is the user's by design: {parked:?}",
1006 );
1007
1008 let untouched = facade
1009 .update_task(
1010 &lead,
1011 &TaskId::from("plain"),
1012 TaskPatch { parked: Some(true), ..TaskPatch::default() },
1013 )
1014 .expect("the plain row is there");
1015 assert_eq!(untouched.waiting_on, None, "a row with no wait invents none");
1016 }
1017
1018 #[test]
1019 fn update_returns_the_record_it_wrote() {
1020 let (ws, facade, lead, _worker) = fixture();
1021 let mut seeded = seeded_task("t-1", "before");
1024 seeded.parent = Some(TaskId::from("epic"));
1025 ws.seed_test_task(seeded);
1026
1027 let updated = facade
1028 .update_task(
1029 &lead,
1030 &TaskId::from("t-1"),
1031 TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1032 )
1033 .expect("the task is there to move");
1034
1035 assert_eq!(updated.id, TaskId::from("t-1"), "the echoed record is the task that moved");
1036 assert_eq!(updated.subject, "before", "carrying the row's own words");
1037 assert_eq!(updated.status, TaskStatus::Completed, "and the state it now holds");
1038 assert!(
1039 updated.updated_at > SystemTime::UNIX_EPOCH,
1040 "the write stamps `updated_at`; a record echoed without it keeps the epoch: {:?}",
1041 updated.updated_at,
1042 );
1043 assert_eq!(
1044 ws.tasks_for_project("myproj")[0].status,
1045 TaskStatus::Completed,
1046 "and it is the stored record, not a copy the write left behind",
1047 );
1048 }
1049
1050 #[test]
1057 fn a_stated_prefix_lands_the_combined_update() {
1058 let (ws, facade, lead, _worker) = fixture();
1059 let id = "2b5387e5-24d6-4ae6-a69f-0f1f86f158f7";
1060 ws.seed_test_task(seeded_task(id, "the row"));
1061 let stated = &id[..id.len() - 1];
1062 assert!(
1063 id.starts_with(stated) && stated != id,
1064 "the stated id is the row's own prefix, one character shorter",
1065 );
1066
1067 let updated = facade
1068 .update_task(
1069 &lead,
1070 &TaskId::from(stated),
1071 TaskPatch {
1072 detail: Some("why".to_owned()),
1073 links_add: vec![TaskLinkDraft {
1074 kind: Some(LinkKind::Pr),
1075 label: Some("PR #2097".to_owned()),
1076 target: "https://example.invalid/pull/2097".to_owned(),
1077 }],
1078 ..TaskPatch::default()
1079 },
1080 )
1081 .expect("a unique prefix of the row's own id still names the row");
1082
1083 assert_eq!(updated.id, TaskId::from(id), "the prefix resolves to the stored id");
1084 assert_eq!(updated.detail.as_deref(), Some("why"), "the detail lands with the link");
1085 assert_eq!(updated.links.len(), 1, "the link lands with the detail");
1086 assert_eq!(updated.links[0].target, "https://example.invalid/pull/2097");
1087 }
1088
1089 #[test]
1095 fn the_incidents_corrupted_id_names_no_row() {
1096 let (ws, facade, lead, _worker) = fixture();
1097 let id = "2b5387e5-24d6-4ae6-a69f-0f1f86f158f7";
1098 let stated = "2b5387e5-24d6-4ae6-a69f-0f1f86f1587";
1099 ws.seed_test_task(seeded_task(id, "the row"));
1100 assert!(!id.starts_with(stated), "the drop is interior, so the stated id is no prefix");
1101
1102 let updated = facade
1103 .update_task(
1104 &lead,
1105 &TaskId::from(stated),
1106 TaskPatch {
1107 detail: Some("why".to_owned()),
1108 links_add: vec![TaskLinkDraft {
1109 kind: Some(LinkKind::Pr),
1110 label: Some("PR #2097".to_owned()),
1111 target: "https://example.invalid/pull/2097".to_owned(),
1112 }],
1113 ..TaskPatch::default()
1114 },
1115 )
1116 .expect_err("a corrupted id names no row");
1117 assert_eq!(
1118 updated,
1119 TasksError::NoSuchTask { id: stated.to_owned() },
1120 "an id with a character dropped from inside names no row",
1121 );
1122
1123 let stored = ws.tasks_for_project("myproj");
1124 assert_eq!(stored[0].detail, None, "the row's detail is untouched");
1125 assert!(stored[0].links.is_empty(), "and no link attaches under a corrupted id");
1126 }
1127
1128 #[test]
1139 fn delete_returns_the_tree_it_removed() {
1140 let (_ws, facade, lead, _worker) = fixture();
1141 facade.create_task(&lead, draft("sibling", None, None)).expect("sibling");
1142 let b = facade.create_task(&lead, draft("b", None, None)).expect("b");
1143 let c = facade.create_task(&lead, draft("c", None, None)).expect("c");
1144 facade.create_task(&lead, draft("d", None, Some(b.id.as_str()))).expect("d, b's child");
1145 facade
1146 .update_task(
1147 &lead,
1148 &b.id,
1149 TaskPatch { parent: Some(c.id.as_str().to_owned()), ..TaskPatch::default() },
1150 )
1151 .expect("b is there to move");
1152
1153 let removed =
1154 facade.delete_task(&lead, &c.id).expect("the task the call named is there to remove");
1155
1156 assert_eq!(
1157 removed.task.id, c.id,
1158 "the record is the task named by the call, not the first record the cascade removed",
1159 );
1160 assert_eq!(removed.task.subject, "c", "as it stood just before removal");
1161 assert_eq!(removed.descendants_removed, 2, "b and d went with it, and the count says so");
1162 assert_eq!(
1163 facade.list_tasks(&lead, None, None, None, None).len(),
1164 1,
1165 "only the sibling survives"
1166 );
1167 }
1168
1169 #[test]
1173 fn delete_resolves_a_stated_id_by_exact_or_unique_prefix() {
1174 let (ws, facade, lead, _worker) = fixture();
1175 ws.seed_test_task(seeded_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail"));
1176 ws.seed_test_task(seeded_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests"));
1177
1178 let said = match facade.delete_task(&lead, &TaskId::from("7ed0")) {
1179 Err(TasksError::Refused(said)) => said,
1180 other => panic!("a refusal naming the rows it matched, got {other:?}"),
1181 };
1182 assert!(
1183 said.contains("7ed0aaaa-0000-4000-8000-000000000001")
1184 && said.contains("7ed0bbbb-0000-4000-8000-000000000002"),
1185 "the refusal names both rows it matched: {said}",
1186 );
1187
1188 let removed = facade
1189 .delete_task(&lead, &TaskId::from("7ed0bb"))
1190 .expect("one row carries that prefix");
1191 assert_eq!(removed.task.id, TaskId::from("7ed0bbbb-0000-4000-8000-000000000002"));
1192 assert_eq!(removed.task.subject, "add tests", "the prefix removed its own row");
1193 assert_eq!(
1194 facade.list_tasks(&lead, None, None, None, None).len(),
1195 1,
1196 "and left the other row alone",
1197 );
1198 }
1199
1200 #[test]
1205 fn delete_refuses_a_row_waiting_on_the_look() {
1206 let (_ws, facade, lead, _worker) = fixture();
1207 let gated = facade.create_task(&lead, draft("gated", None, None)).expect("gated");
1208 facade
1209 .update_task(
1210 &lead,
1211 &gated.id,
1212 TaskPatch {
1213 verify: Some(forge_primitives::tasks::Verify::User),
1214 ..TaskPatch::default()
1215 },
1216 )
1217 .expect("arm the gate");
1218 facade
1219 .update_task(
1220 &lead,
1221 &gated.id,
1222 TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1223 )
1224 .expect("the completion lands on the look");
1225
1226 let said = match facade.delete_task(&lead, &gated.id) {
1227 Err(TasksError::Refused(said)) => said,
1228 other => panic!("a refusal naming the row, got {other:?}"),
1229 };
1230 assert!(said.contains("gated"), "the refusal names the row: {said}");
1231 assert_eq!(
1232 facade.list_tasks(&lead, None, None, None, None).len(),
1233 1,
1234 "and the row is still there to be looked at",
1235 );
1236 }
1237
1238 #[test]
1243 fn a_stated_parent_is_stored_as_the_row_it_names() {
1244 let (ws, facade, lead, worker) = fixture();
1245 let epic = "7ed0aaaa-0000-4000-8000-000000000001";
1246 ws.seed_test_task(seeded_task(epic, "the epic"));
1247
1248 let child =
1249 facade.create_task(&lead, draft("the child", None, Some("7ed0aaaa"))).expect("create");
1250 assert_eq!(
1251 child.parent,
1252 Some(TaskId::from(epic)),
1253 "the store holds the row the stated prefix named",
1254 );
1255
1256 let claimed = facade
1257 .claim_task(&worker, None, Some("7ed0aaaa"))
1258 .expect("the epic's own prefix pulls the child it now holds");
1259 assert_eq!(claimed.id, child.id);
1260
1261 let moved = facade
1262 .update_task(
1263 &lead,
1264 &child.id,
1265 TaskPatch { parent: Some("7ed0aaaa".to_owned()), ..TaskPatch::default() },
1266 )
1267 .expect("the child is there");
1268 assert_eq!(moved.parent, Some(TaskId::from(epic)), "the patch resolves it the same way");
1269 }
1270
1271 #[test]
1275 fn a_parent_naming_nothing_stays_and_naming_several_refuses() {
1276 let (ws, facade, lead, _worker) = fixture();
1277 ws.seed_test_task(seeded_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail"));
1278 ws.seed_test_task(seeded_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests"));
1279
1280 let orphan = facade
1281 .create_task(&lead, draft("orphan", None, Some("ghost")))
1282 .expect("a parent naming nothing is stored as stated");
1283 assert_eq!(orphan.parent, Some(TaskId::from("ghost")));
1284
1285 let said = match facade.create_task(&lead, draft("ambiguous", None, Some("7ed0"))) {
1286 Err(TasksError::Refused(said)) => said,
1287 other => panic!("a parent naming several rows refuses, got {other:?}"),
1288 };
1289 assert!(
1290 said.contains("7ed0aaaa-0000-4000-8000-000000000001")
1291 && said.contains("7ed0bbbb-0000-4000-8000-000000000002"),
1292 "the refusal names both rows it matched: {said}",
1293 );
1294 }
1295
1296 #[test]
1299 fn list_narrows_by_a_stated_parent_prefix() {
1300 let (ws, facade, lead, _worker) = fixture();
1301 let epic = "7ed0aaaa-0000-4000-8000-000000000001";
1302 ws.seed_test_task(seeded_task(epic, "the epic"));
1303 facade.create_task(&lead, draft("child", None, Some(epic))).expect("child");
1304 facade.create_task(&lead, draft("sibling", None, None)).expect("sibling");
1305
1306 let kids = facade.list_tasks(&lead, None, Some(&TaskId::from("7ed0aaaa")), None, None);
1307 assert_eq!(kids.len(), 1, "the prefix names the parent row: {kids:?}");
1308 assert_eq!(kids[0].task.subject, "child");
1309 }
1310
1311 #[test]
1318 fn a_lead_completing_a_workers_row_that_waits_on_the_look_is_refused_by_name() {
1319 let (_ws, facade, lead, worker) = fixture();
1320 let task = facade
1321 .create_task(
1322 &worker,
1323 TaskDraft {
1324 verify: Some(forge_primitives::tasks::Verify::User),
1325 ..draft("the parked row", Some("reviewer"), Some("epic"))
1326 },
1327 )
1328 .expect("create");
1329 let handed = facade
1330 .update_task(
1331 &worker,
1332 &task.id,
1333 TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1334 )
1335 .expect("the row is there");
1336 assert_eq!(
1337 handed.status,
1338 TaskStatus::Waiting,
1339 "precondition: the gate hands the worker's row to the look",
1340 );
1341
1342 match facade.update_task(
1343 &lead,
1344 &task.id,
1345 TaskPatch {
1346 owner: Some("lead".to_owned()),
1347 status: Some(TaskStatus::Completed),
1348 ..TaskPatch::default()
1349 },
1350 ) {
1351 Err(TasksError::Refused(said)) => {
1352 assert!(
1353 said.contains("waiting on the user's look"),
1354 "the refusal names the look, not another move error's words: {said}",
1355 );
1356 }
1357 other => panic!(
1358 "the lead's completion of a row waiting on the look is refused, got {other:?}"
1359 ),
1360 }
1361 }
1362
1363 #[test]
1367 fn a_cancel_through_the_facade_records_the_reason_and_a_bare_one_is_refused() {
1368 let (_ws, facade, lead, worker) = fixture();
1369 let task = facade
1370 .create_task(
1371 &worker,
1372 TaskDraft {
1373 verify: Some(forge_primitives::tasks::Verify::User),
1374 ..draft("the parked row", Some("reviewer"), Some("epic"))
1375 },
1376 )
1377 .expect("create");
1378 facade
1379 .update_task(
1380 &worker,
1381 &task.id,
1382 TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1383 )
1384 .expect("the row is there");
1385
1386 match facade.update_task(
1387 &lead,
1388 &task.id,
1389 TaskPatch { status: Some(TaskStatus::Canceled), ..TaskPatch::default() },
1390 ) {
1391 Err(TasksError::Refused(said)) => assert!(
1392 said.contains("withdrawing it needs `detail`"),
1393 "the refusal names the door's requirement: {said}",
1394 ),
1395 other => panic!("a bare cancel of a parked row is refused, got {other:?}"),
1396 }
1397
1398 let withdrawn = facade
1399 .update_task(
1400 &lead,
1401 &task.id,
1402 TaskPatch {
1403 status: Some(TaskStatus::Canceled),
1404 detail: Some("moot: landed via #2296".to_owned()),
1405 ..TaskPatch::default()
1406 },
1407 )
1408 .expect("the row is there");
1409 assert_eq!(withdrawn.status, TaskStatus::Canceled, "the withdraw lands");
1410 assert_eq!(withdrawn.waiting_on, None, "the ask leaves the waiting set");
1411 assert_eq!(
1412 withdrawn.detail.as_deref(),
1413 Some("moot: landed via #2296"),
1414 "and the reason lands on the row",
1415 );
1416 }
1417
1418 #[test]
1422 fn a_clear_of_a_user_verify_is_refused_by_name() {
1423 let (ws, facade, lead, worker) = fixture();
1424 let task = facade
1425 .create_task(
1426 &worker,
1427 TaskDraft {
1428 verify: Some(forge_primitives::tasks::Verify::User),
1429 ..draft("the gated row", Some("reviewer"), Some("epic"))
1430 },
1431 )
1432 .expect("create");
1433
1434 let refused = facade.update_task(
1435 &lead,
1436 &task.id,
1437 TaskPatch {
1438 status: Some(TaskStatus::Completed),
1439 verify: Some(forge_primitives::tasks::Verify::None),
1440 ..TaskPatch::default()
1441 },
1442 );
1443 match refused {
1444 Err(TasksError::Refused(said)) => assert!(
1445 said.contains("an update cannot take that away"),
1446 "the refusal says the gate is not the update's to clear: {said}",
1447 ),
1448 other => panic!("the clear-and-complete call is refused, got {other:?}"),
1449 }
1450 let stored = ws.tasks_for_project("myproj");
1451 assert_eq!(
1452 stored[0].verify,
1453 Some(forge_primitives::tasks::Verify::User),
1454 "and the row keeps the gate",
1455 );
1456 }
1457
1458 #[test]
1462 fn an_unparseable_estimate_is_refused_and_moves_nothing() {
1463 let (ws, facade, lead, _worker) = fixture();
1464 let task = facade.create_task(&lead, draft("before", None, None)).expect("create");
1465 let refused = facade.update_task(
1466 &lead,
1467 &task.id,
1468 TaskPatch {
1469 subject: Some("after".to_owned()),
1470 estimate: Some("soonish".to_owned()),
1471 ..TaskPatch::default()
1472 },
1473 );
1474 assert_eq!(
1475 refused,
1476 Err(TasksError::BadEstimate("soonish".to_owned())),
1477 "the refusal names the words it could not read",
1478 );
1479 assert_eq!(
1480 ws.tasks_for_project("myproj")[0].subject,
1481 "before",
1482 "and no field moved on the refused write",
1483 );
1484 }
1485
1486 #[test]
1487 fn create_refuses_an_estimate_it_cannot_parse() {
1488 let (_ws, facade, lead, _worker) = fixture();
1489 assert_eq!(
1490 facade.create_task(
1491 &lead,
1492 TaskDraft { estimate: Some("tomorrow".to_owned()), ..draft("x", None, None) },
1493 ),
1494 Err(TasksError::BadEstimate("tomorrow".to_owned())),
1495 );
1496 }
1497
1498 #[test]
1501 fn ready_narrows_out_rows_something_holds() {
1502 let (ws, facade, lead, _worker) = fixture();
1503 let mut held = seeded_task("t-1", "held");
1504 held.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1505 held.status = TaskStatus::InProgress;
1506 ws.seed_test_task(held);
1507 ws.seed_test_task(seeded_task("t-2", "free"));
1508
1509 let ready = facade.list_tasks(&lead, None, None, None, Some(true));
1510 assert_eq!(ready.len(), 1, "only the free row is ready: {ready:?}");
1511 assert_eq!(ready[0].task.id, TaskId::from("t-2"));
1512 assert!(ready[0].marks.ready, "and it carries the mark it was filtered by");
1513 }
1514
1515 #[test]
1518 fn a_patch_moves_every_field_it_can_state() {
1519 let (ws, facade, lead, _worker) = fixture();
1520 let task = facade.create_task(&lead, draft("before", None, None)).expect("create");
1521 facade
1522 .update_task(
1523 &lead,
1524 &task.id,
1525 TaskPatch {
1526 subject: Some("after".to_owned()),
1527 active_form: Some("doing".to_owned()),
1528 detail: Some("why".to_owned()),
1529 status: Some(TaskStatus::Waiting),
1530 owner: Some("lead".to_owned()),
1531 parent: Some("epic".to_owned()),
1532 links_add: vec![TaskLinkDraft {
1533 kind: None,
1534 label: None,
1535 target: "PR #9".to_owned(),
1536 }],
1537 estimate: Some("2d".to_owned()),
1538 rank: Some(3),
1539 verify: Some(forge_primitives::tasks::Verify::User),
1540 ..TaskPatch::default()
1541 },
1542 )
1543 .expect("the task is there to move");
1544 let stored = &ws.tasks_for_project("myproj")[0];
1545 assert_eq!(stored.subject, "after");
1546 assert_eq!(stored.active_form.as_deref(), Some("doing"));
1547 assert_eq!(stored.detail.as_deref(), Some("why"));
1548 assert_eq!(stored.status, TaskStatus::Waiting);
1549 assert_eq!(stored.owner.as_ref().map(SessionSlot::label), Some("lead"));
1550 assert_eq!(stored.parent.as_ref().map(TaskId::as_str), Some("epic"));
1551 assert_eq!(
1552 stored.links.first().map(|l| l.target.as_str()),
1553 Some("PR #9"),
1554 "the stated artifact lands as a link",
1555 );
1556 assert_eq!(
1557 stored.estimate.as_ref().map(|e| e.words.as_str()),
1558 Some("2d"),
1559 "and the estimate keeps its words with its seconds parsed",
1560 );
1561 assert_eq!(stored.estimate.as_ref().map(|e| e.secs), Some(172_800));
1562 assert_eq!(stored.rank, Some(3), "rank moves");
1563 assert_eq!(stored.verify, Some(forge_primitives::tasks::Verify::User), "verify moves");
1564
1565 facade
1567 .update_task(
1568 &lead,
1569 &task.id,
1570 TaskPatch {
1571 links_add: vec![TaskLinkDraft {
1572 kind: None,
1573 label: None,
1574 target: "PR #9".to_owned(),
1575 }],
1576 ..TaskPatch::default()
1577 },
1578 )
1579 .expect("the task is there");
1580 assert_eq!(
1581 ws.tasks_for_project("myproj")[0].links.len(),
1582 1,
1583 "a re-added target stays one link",
1584 );
1585 facade
1586 .update_task(
1587 &lead,
1588 &task.id,
1589 TaskPatch { links_remove: vec!["PR #9".to_owned()], ..TaskPatch::default() },
1590 )
1591 .expect("the task is there");
1592 assert!(
1593 ws.tasks_for_project("myproj")[0].links.is_empty(),
1594 "remove takes it off by exact target",
1595 );
1596 }
1597
1598 #[test]
1599 fn update_moves_a_task_owned_by_another_session() {
1600 let (ws, facade, lead, worker) = fixture();
1601 let task =
1603 facade.create_task(&worker, draft("review this", None, Some("epic"))).expect("create");
1604 facade
1605 .update_task(
1606 &lead,
1607 &task.id,
1608 TaskPatch {
1609 status: Some(TaskStatus::Completed),
1610 owner: Some("lead".to_owned()),
1611 ..TaskPatch::default()
1612 },
1613 )
1614 .expect("the lead completes a worker's task");
1615 let stored = ws.tasks_for_project("myproj");
1616 assert_eq!(stored[0].status, TaskStatus::Completed, "the status moved");
1617 assert_eq!(
1618 stored[0].owner.as_ref().map(SessionSlot::label),
1619 Some("lead"),
1620 "the owner moved to the lead's label",
1621 );
1622 }
1623}