Skip to main content

jmap_tasks_server/
lib.rs

1//! JMAP Tasks extension method handlers (draft-ietf-jmap-tasks-06).
2//!
3//! # Usage
4//!
5//! Implement [`TasksBackend`] for your storage layer, then call
6//! [`register_tasks_handlers`] to wire all method names into a
7//! [`jmap_server::Dispatcher`]:
8//!
9//! ```rust,no_run
10//! # use std::sync::Arc;
11//! # use jmap_tasks_server::{TasksBackend, register_tasks_handlers};
12//! # use jmap_server::{Dispatcher, JmapBackend};
13//! # fn example<B: TasksBackend<CallerCtx = ()> + 'static>(backend: B) {
14//! let mut dispatcher: Dispatcher<()> = Dispatcher::new();
15//! register_tasks_handlers(&mut dispatcher, Arc::new(backend));
16//! # }
17//! ```
18//!
19//! # `memory` feature (reference implementation)
20//!
21//! Enable the `memory` feature to expose the `memory::MemoryBackend`
22//! reference implementation of [`TasksBackend`]. This is the same backend
23//! used by this crate's own integration tests, intended for downstream
24//! contributors to study and for smoke tests / examples that do not want
25//! to stand up a real database. **Not production.** API stability is
26//! opt-in via this feature and may break across minor versions while the
27//! crate is pre-1.0.
28
29#![forbid(unsafe_code)]
30
31use std::sync::Arc;
32
33use jmap_server::{Dispatcher, HandlerFuture, JmapHandler};
34
35pub mod backend;
36mod helpers;
37/// In-memory reference implementation of [`TasksBackend`].
38///
39/// Gated behind `feature = "memory"`. Not production. See [`memory`] for
40/// the full module documentation.
41#[cfg(feature = "memory")]
42pub mod memory;
43pub mod task;
44pub mod task_list;
45pub mod task_notification;
46
47pub use backend::{
48    AddedItem, BackendChangesError, BackendSetError, ChangesResult, GetObject, JmapBackend,
49    JmapObject, QueryChangesResult, QueryObject, QueryResult, SetError, SetErrorType, SetObject,
50    TaskListProperty, TaskNotificationProperty, TaskProperty, TasksBackend,
51};
52pub use task::{
53    handle_task_changes, handle_task_copy, handle_task_get, handle_task_query,
54    handle_task_query_changes, handle_task_set,
55};
56pub use task_list::{handle_task_list_changes, handle_task_list_get, handle_task_list_set};
57pub use task_notification::{
58    handle_task_notification_changes, handle_task_notification_get, handle_task_notification_query,
59    handle_task_notification_query_changes, handle_task_notification_set,
60};
61
62/// Capability URI for `urn:ietf:params:jmap:tasks`.
63pub use jmap_tasks_types::JMAP_TASKS_URI;
64
65// ---------------------------------------------------------------------------
66// register_tasks_handlers — the main entry point for consumers
67// ---------------------------------------------------------------------------
68
69/// Register all JMAP Tasks method handlers with `dispatcher`.
70///
71/// `backend` is wrapped in [`Arc`] so it is cloned cheaply into each handler.
72/// You may pass any `Arc<B>` — the function clones it internally into each
73/// registered handler closure. Sharing the same `Arc<B>` across this call
74/// and other application-level uses of the backend is a memory
75/// optimization, not a correctness requirement; separate `Arc<B>` instances
76/// pointing at the same underlying backend would also work.
77///
78/// After this call, the dispatcher handles:
79/// `TaskList/get`, `TaskList/changes`, `TaskList/set`,
80/// `Task/get`, `Task/changes`, `Task/set`, `Task/copy`,
81/// `Task/query`, `Task/queryChanges`,
82/// `TaskNotification/get`, `TaskNotification/changes`,
83/// `TaskNotification/set`, `TaskNotification/query`,
84/// `TaskNotification/queryChanges`.
85///
86/// # Re-registration semantics
87///
88/// This function calls [`Dispatcher::register`] once per
89/// draft-ietf-jmap-tasks-06 method name. `Dispatcher::register`
90/// **silently overwrites** any pre-existing handler under the same
91/// method name (the underlying primitive is `HashMap::insert`). Three
92/// consequences callers MUST be aware of:
93///
94/// - **Double-call**: invoking this function twice on the same
95///   dispatcher loses the first set's handlers. The second call wins.
96/// - **Custom overrides go LAST**: to replace a single handler (e.g.
97///   provide a custom `Task/get`), call this function FIRST, then
98///   `dispatcher.register("Task/get", my_override)`. The inverse
99///   order silently undoes the custom handler.
100/// - **No collision diagnostic**: there is no error or log when a
101///   handler is overwritten. The contract is "last register wins" and
102///   the caller is responsible for ordering.
103///
104/// [`Dispatcher::register`]: jmap_server::Dispatcher::register
105pub fn register_tasks_handlers<B>(dispatcher: &mut Dispatcher<B::CallerCtx>, backend: Arc<B>)
106where
107    B: TasksBackend + 'static,
108{
109    // Helper: register one method with a closure taking
110    // (Arc<B>, call_id, args, ctx). `$ctx` is the per-request caller context
111    // (`B::CallerCtx`) forwarded by the dispatcher; closures pass `&ctx` to the
112    // inner `handle_*` fn. `$ci` is the call_id string — most handlers ignore
113    // it (`_ci`); only `Task/copy` uses it.
114    macro_rules! reg {
115        ($method:expr, $backend:expr, |$b:ident, $ci:ident, $a:ident, $ctx:ident| $body:expr) => {{
116            let backend_arc: Arc<B> = Arc::clone(&$backend);
117            let h: Arc<dyn JmapHandler<B::CallerCtx>> = Arc::new(ClosureHandler::new(
118                backend_arc,
119                move |$b: Arc<B>, $ci: String, $a: serde_json::Value, $ctx: B::CallerCtx| {
120                    Box::pin(async move { $body }) as HandlerFuture
121                },
122            ));
123            dispatcher.register($method, h);
124        }};
125    }
126
127    // TaskList
128    reg!("TaskList/get", backend, |b, _ci, a, ctx| {
129        handle_task_list_get(&*b, &ctx, a).await
130    });
131    reg!("TaskList/changes", backend, |b, _ci, a, ctx| {
132        handle_task_list_changes(&*b, &ctx, a).await
133    });
134    reg!("TaskList/set", backend, |b, _ci, a, ctx| {
135        handle_task_list_set(&*b, &ctx, a).await
136    });
137
138    // Task
139    reg!("Task/get", backend, |b, _ci, a, ctx| {
140        handle_task_get(&*b, &ctx, a).await
141    });
142    reg!("Task/changes", backend, |b, _ci, a, ctx| {
143        handle_task_changes(&*b, &ctx, a).await
144    });
145    reg!("Task/set", backend, |b, _ci, a, ctx| {
146        handle_task_set(&*b, &ctx, a).await
147    });
148    reg!("Task/copy", backend, |b, ci, a, ctx| {
149        handle_task_copy(&*b, &ctx, a, &ci).await
150    });
151    reg!("Task/query", backend, |b, _ci, a, ctx| {
152        handle_task_query(&*b, &ctx, a).await
153    });
154    reg!("Task/queryChanges", backend, |b, _ci, a, ctx| {
155        handle_task_query_changes(&*b, &ctx, a).await
156    });
157
158    // TaskNotification
159    reg!("TaskNotification/get", backend, |b, _ci, a, ctx| {
160        handle_task_notification_get(&*b, &ctx, a).await
161    });
162    reg!("TaskNotification/changes", backend, |b, _ci, a, ctx| {
163        handle_task_notification_changes(&*b, &ctx, a).await
164    });
165    reg!("TaskNotification/set", backend, |b, _ci, a, ctx| {
166        handle_task_notification_set(&*b, &ctx, a).await
167    });
168    reg!("TaskNotification/query", backend, |b, _ci, a, ctx| {
169        handle_task_notification_query(&*b, &ctx, a).await
170    });
171    reg!(
172        "TaskNotification/queryChanges",
173        backend,
174        |b, _ci, a, ctx| handle_task_notification_query_changes(&*b, &ctx, a).await
175    );
176}
177
178/// Handler-wrapper type for registering custom JMAP method handlers on a
179/// [`jmap_server::Dispatcher`] without writing a full `JmapHandler` impl
180/// from scratch. Most consumers should NOT need this — the
181/// [`register_tasks_handlers`] function above registers all 14 method
182/// names automatically. Reach for `ClosureHandler` only when extending
183/// the dispatcher with site-specific JMAP methods that are not part of
184/// the standard Tasks surface.
185/// Generic closure-to-[`JmapHandler`] adapter from [`jmap_server`].
186///
187/// Re-exported so the [`register_tasks_handlers`] macro body can name
188/// `ClosureHandler` without a fully-qualified path. **Stability**: this
189/// re-export pins the major-version contract of [`jmap_server::ClosureHandler`]
190/// into this crate's public surface — a breaking change to that type
191/// upstream is a breaking change here. Consumers needing a closure handler
192/// adapter SHOULD prefer importing from [`jmap_server`] directly; the
193/// re-export is retained primarily for the in-crate macro and for
194/// backward-compatible spelling of the existing handler-registration
195/// pattern.
196pub use jmap_server::ClosureHandler;
197
198// ---------------------------------------------------------------------------
199// test_support — in-memory mock backend used by inline tests
200// ---------------------------------------------------------------------------
201
202#[cfg(test)]
203#[deny(clippy::await_holding_lock)]
204pub(crate) mod test_support {
205    //! In-memory mock backend for unit tests.
206
207    use std::collections::HashMap;
208    use std::sync::atomic::{AtomicU32, Ordering};
209    use std::sync::{Arc, Mutex};
210
211    use jmap_server::{
212        BackendChangesError, BackendSetError, ChangesResult, GetObject, JmapBackend, JmapObject,
213        QueryChangesResult, QueryObject, QueryResult, SetError, SetErrorType, SetObject,
214    };
215    use jmap_tasks_types::{Task, TaskList, TaskNotification};
216    use jmap_types::{Id, State};
217
218    use crate::backend::TasksBackend;
219
220    /// Minimal error type for the mock backend.
221    #[derive(Debug)]
222    pub struct MockError(pub String);
223
224    impl std::fmt::Display for MockError {
225        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226            write!(f, "mock error: {}", self.0)
227        }
228    }
229
230    impl std::error::Error for MockError {}
231
232    /// In-memory state for one account.
233    #[derive(Default, Clone)]
234    struct AccountState {
235        task_lists: HashMap<Id, TaskList>,
236        tasks: HashMap<Id, Task>,
237        notifications: HashMap<Id, TaskNotification>,
238        task_list_state: u64,
239        task_state: u64,
240        notification_state: u64,
241    }
242
243    /// In-memory mock backend for testing.
244    #[derive(Clone)]
245    pub struct MockBackend {
246        state: Arc<Mutex<HashMap<String, AccountState>>>,
247        /// Counts how many times `update_task_per_user` was called.
248        /// Used by integration tests to verify per-user routing.
249        pub per_user_calls: Arc<AtomicU32>,
250    }
251
252    impl MockBackend {
253        /// Create a backend with no accounts registered.
254        pub fn new() -> Self {
255            Self {
256                state: Arc::new(Mutex::new(HashMap::new())),
257                per_user_calls: Arc::new(AtomicU32::new(0)),
258            }
259        }
260
261        /// Create a backend with the given account already registered.
262        pub fn new_with_account(account_id: &str) -> Self {
263            let b = Self::new();
264            b.state
265                .lock()
266                .unwrap()
267                .insert(account_id.to_owned(), AccountState::default());
268            b
269        }
270
271        /// Register an additional account on an existing backend. Used by
272        /// Task/copy tests that need two valid accounts (`fromAccountId`
273        /// and `accountId` must differ per RFC 8620 §5.4).
274        pub fn add_account(&mut self, account_id: &str) {
275            self.state
276                .lock()
277                .unwrap()
278                .entry(account_id.to_owned())
279                .or_default();
280        }
281
282        /// Pre-populate a TaskNotification in the given account.
283        pub fn add_notification(&mut self, account_id: &str, notif_id: &str) {
284            let notif: TaskNotification = serde_json::from_value(serde_json::json!({
285                "id": notif_id,
286                "created": "2024-01-01T00:00:00Z",
287                "changedBy": { "@type": "Person", "name": "Test" },
288                "type": "created",
289                "taskId": "task1"
290            }))
291            .expect("test fixture must deserialize");
292            let mut guard = self.state.lock().unwrap();
293            let acct = guard.entry(account_id.to_owned()).or_default();
294            acct.notifications.insert(Id::from(notif_id), notif);
295        }
296
297        /// Pre-populate a Task with a specific `isDraft` value in the given account.
298        ///
299        /// Used by tests for the isDraft immutability enforcement path.
300        pub fn seed_task(&mut self, account_id: &str, task_id: &str, is_draft: bool) {
301            let task: Task = serde_json::from_value(serde_json::json!({
302                "id": task_id,
303                "isDraft": is_draft
304            }))
305            .expect("task seed fixture must deserialize");
306            let mut guard = self.state.lock().unwrap();
307            let acct = guard.entry(account_id.to_owned()).or_default();
308            acct.tasks.insert(Id::from(task_id), task);
309        }
310
311        /// Pre-populate a TaskList with a task in the given account.
312        pub fn add_task_list_with_task(&mut self, account_id: &str, list_id: &str) {
313            let task_list: TaskList = serde_json::from_value(serde_json::json!({
314                "id": list_id,
315                "name": "Test List",
316                "sortOrder": 0,
317                "isSubscribed": true,
318                "myRights": {
319                    "mayReadItems": true,
320                    "mayWriteAll": true,
321                    "mayWriteOwn": true,
322                    "mayUpdatePrivate": true,
323                    "mayRSVP": true,
324                    "mayAdmin": true,
325                    "mayDelete": true
326                }
327            }))
328            .expect("task list fixture must deserialize");
329            let mut guard = self.state.lock().unwrap();
330            let acct = guard.entry(account_id.to_owned()).or_default();
331            acct.task_lists.insert(Id::from(list_id), task_list);
332            // Add a task referencing the list
333            let task: Task = serde_json::from_value(serde_json::json!({
334                "id": "task1",
335                "taskListId": list_id
336            }))
337            .expect("task fixture must deserialize");
338            acct.tasks.insert(Id::from("task1"), task);
339        }
340    }
341
342    impl JmapBackend for MockBackend {
343        type Error = MockError;
344        type CallerCtx = ();
345
346        async fn account_exists(&self, _caller: &(), account_id: &Id) -> Result<bool, Self::Error> {
347            Ok(self.state.lock().unwrap().contains_key(account_id.as_ref()))
348        }
349
350        async fn get_objects<O: GetObject + Send + Sync>(
351            &self,
352            _caller: &(),
353            account_id: &Id,
354            ids: Option<&[Id]>,
355            _properties: Option<&[String]>,
356        ) -> Result<(Vec<O>, Vec<Id>), Self::Error> {
357            // Attempt to serve from the tasks store via a serde round-trip.
358            // When O = Task this is identity; for other types the deserialize
359            // step will fail (no data in the store matches) and we fall back
360            // to empty — preserving the prior behaviour for TaskList/get etc.
361            let guard = self.state.lock().unwrap();
362            let Some(acct) = guard.get(account_id.as_ref()) else {
363                return Ok((vec![], vec![]));
364            };
365
366            let mut found: Vec<O> = Vec::new();
367            let mut not_found: Vec<Id> = Vec::new();
368
369            if let Some(id_slice) = ids {
370                for id in id_slice {
371                    if let Some(task) = acct.tasks.get(id) {
372                        match serde_json::to_value(task)
373                            .ok()
374                            .and_then(|v| serde_json::from_value::<O>(v).ok())
375                        {
376                            Some(obj) => found.push(obj),
377                            None => not_found.push(id.clone()),
378                        }
379                    } else {
380                        not_found.push(id.clone());
381                    }
382                }
383            }
384            // If no specific ids were requested, serve nothing (empty list) —
385            // the isDraft check always passes specific ids.
386            Ok((found, not_found))
387        }
388
389        async fn get_state<O: JmapObject + Send + Sync>(
390            &self,
391            _caller: &(),
392            _account_id: &Id,
393        ) -> Result<State, Self::Error> {
394            Ok(State::from("0"))
395        }
396
397        async fn get_changes<O: JmapObject + Send + Sync>(
398            &self,
399            _caller: &(),
400            _account_id: &Id,
401            _since_state: &State,
402            _max_changes: Option<u64>,
403        ) -> Result<ChangesResult, BackendChangesError<Self::Error>> {
404            Ok(ChangesResult::new(
405                vec![],
406                vec![],
407                vec![],
408                false,
409                State::from("0"),
410            ))
411        }
412
413        async fn query_objects<O: QueryObject + Send + Sync>(
414            &self,
415            _caller: &(),
416            _account_id: &Id,
417            _filter: Option<&O::Filter>,
418            _sort: Option<&[O::Comparator]>,
419            _limit: Option<u64>,
420            _position: i64,
421        ) -> Result<QueryResult, Self::Error> {
422            Ok(QueryResult::new(
423                vec![],
424                0,
425                Some(0),
426                State::from("0"),
427                false,
428            ))
429        }
430
431        async fn query_changes<O: QueryObject + Send + Sync>(
432            &self,
433            _caller: &(),
434            _account_id: &Id,
435            since_query_state: &State,
436            _filter: Option<&O::Filter>,
437            _sort: Option<&[O::Comparator]>,
438            _max_changes: Option<u64>,
439            _up_to_id: Option<&Id>,
440            _collapse_threads: bool,
441        ) -> Result<QueryChangesResult, BackendChangesError<Self::Error>> {
442            Ok(QueryChangesResult::new(
443                since_query_state.clone(),
444                State::from("0"),
445                Some(0),
446                vec![],
447                vec![],
448            ))
449        }
450    }
451
452    impl TasksBackend for MockBackend {
453        async fn create_object<O: SetObject + Send + Sync>(
454            &self,
455            _caller: &(),
456            _account_id: &Id,
457            _create_id: &str,
458            obj: O,
459        ) -> Result<(Id, O), BackendSetError<Self::Error>> {
460            Ok((Id::from("mock-id-1"), obj))
461        }
462
463        async fn update_object<O: SetObject + Send + Sync>(
464            &self,
465            _caller: &(),
466            _account_id: &Id,
467            _id: &Id,
468            _patch: O::Patch,
469        ) -> Result<Option<O>, BackendSetError<Self::Error>> {
470            Err(BackendSetError::SetError(SetError::new(
471                SetErrorType::Forbidden,
472            )))
473        }
474
475        async fn destroy_object<O: SetObject + Send + Sync>(
476            &self,
477            _caller: &(),
478            account_id: &Id,
479            id: &Id,
480        ) -> Result<(), BackendSetError<Self::Error>> {
481            let mut guard = self.state.lock().unwrap();
482            if let Some(acct) = guard.get_mut(account_id.as_ref()) {
483                if acct.notifications.remove(id).is_some() {
484                    acct.notification_state += 1;
485                    return Ok(());
486                }
487                if acct.tasks.remove(id).is_some() {
488                    acct.task_state += 1;
489                    return Ok(());
490                }
491                if acct.task_lists.remove(id).is_some() {
492                    acct.task_list_state += 1;
493                    return Ok(());
494                }
495            }
496            Err(BackendSetError::SetError(SetError::new(
497                SetErrorType::NotFound,
498            )))
499        }
500
501        fn supports_type<O: JmapObject>(&self) -> bool {
502            true
503        }
504
505        async fn update_task_per_user(
506            &self,
507            caller: &(),
508            account_id: &Id,
509            id: &Id,
510            patch: jmap_types::PatchObject,
511        ) -> Result<Option<Task>, BackendSetError<Self::Error>> {
512            // Track that this per-user path was called.
513            self.per_user_calls.fetch_add(1, Ordering::Relaxed);
514            // Delegate to update_object (same outcome as default impl).
515            self.update_object::<Task>(caller, account_id, id, patch)
516                .await
517        }
518
519        async fn task_list_has_tasks(
520            &self,
521            _caller: &(),
522            account_id: &Id,
523            task_list_id: &Id,
524        ) -> Result<bool, MockError> {
525            let guard = self.state.lock().unwrap();
526            if let Some(acct) = guard.get(account_id.as_ref()) {
527                return Ok(acct.tasks.values().any(|t| {
528                    t.task_list_id
529                        .as_ref()
530                        .is_some_and(|lid| lid == task_list_id)
531                }));
532            }
533            Ok(false)
534        }
535    }
536}
537
538// ---------------------------------------------------------------------------
539// Tests
540// ---------------------------------------------------------------------------
541
542#[cfg(test)]
543mod tests {
544    use std::sync::atomic::Ordering;
545    use std::sync::Arc;
546
547    use jmap_server::{Dispatcher, JmapRequest, State};
548    use serde_json::json;
549
550    use super::*;
551    use crate::test_support::MockBackend;
552
553    // Helper: build a minimal JmapRequest with one method call.
554    fn single_call(method: &str, args: serde_json::Value, call_id: &str) -> JmapRequest {
555        JmapRequest::new(
556            vec!["urn:ietf:params:jmap:tasks".into()],
557            vec![(method.into(), args, call_id.into())],
558            None,
559        )
560    }
561
562    /// Oracle: register_tasks_handlers registers all 14 JMAP Tasks methods.
563    ///
564    /// Verification: each method name returns a non-error response when
565    /// dispatched with a valid account (not `unknownMethod`).
566    #[tokio::test]
567    async fn registers_all_14_methods() {
568        let backend = Arc::new(MockBackend::new_with_account("acc1"));
569        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
570        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
571
572        let methods = [
573            ("TaskList/get", json!({"accountId": "acc1", "ids": null})),
574            (
575                "TaskList/changes",
576                json!({"accountId": "acc1", "sinceState": "0"}),
577            ),
578            ("TaskList/set", json!({"accountId": "acc1", "destroy": []})),
579            ("Task/get", json!({"accountId": "acc1", "ids": null})),
580            (
581                "Task/changes",
582                json!({"accountId": "acc1", "sinceState": "0"}),
583            ),
584            ("Task/set", json!({"accountId": "acc1", "destroy": []})),
585            (
586                "Task/copy",
587                json!({"fromAccountId": "acc1", "accountId": "acc1", "create": {}}),
588            ),
589            (
590                "Task/query",
591                json!({"accountId": "acc1", "filter": null, "sort": null}),
592            ),
593            (
594                "Task/queryChanges",
595                json!({"accountId": "acc1", "sinceQueryState": "0"}),
596            ),
597            (
598                "TaskNotification/get",
599                json!({"accountId": "acc1", "ids": null}),
600            ),
601            (
602                "TaskNotification/changes",
603                json!({"accountId": "acc1", "sinceState": "0"}),
604            ),
605            (
606                "TaskNotification/set",
607                json!({"accountId": "acc1", "destroy": []}),
608            ),
609            (
610                "TaskNotification/query",
611                json!({"accountId": "acc1", "filter": null, "sort": null}),
612            ),
613            (
614                "TaskNotification/queryChanges",
615                json!({"accountId": "acc1", "sinceQueryState": "0"}),
616            ),
617        ];
618
619        for (method, args) in methods {
620            let req = single_call(method, args, "c0");
621            let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
622            assert_eq!(
623                resp.method_responses.len(),
624                1,
625                "{method}: expected 1 response"
626            );
627            let (_, resp_args, _) = &resp.method_responses[0];
628            assert_ne!(
629                resp_args["type"], "unknownMethod",
630                "{method}: must not be unknownMethod — is it registered?"
631            );
632        }
633    }
634
635    /// Oracle: TaskNotification/set with create entries → notCreated contains
636    /// `forbidden` for every create entry; no top-level error.
637    #[tokio::test]
638    async fn task_notification_set_create_returns_forbidden() {
639        let backend = Arc::new(MockBackend::new_with_account("acc1"));
640        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
641        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
642
643        let req = single_call(
644            "TaskNotification/set",
645            json!({
646                "accountId": "acc1",
647                "create": {
648                    "c1": {
649                        "id": "x",
650                        "created": "2024-01-01T00:00:00Z",
651                        "changedBy": { "@type": "Person", "name": "A" },
652                        "type": "created",
653                        "taskId": "t1"
654                    }
655                }
656            }),
657            "c0",
658        );
659        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
660        let (_, args, _) = &resp.method_responses[0];
661        assert!(
662            args.get("type").is_none(),
663            "must not be a top-level error: {args}"
664        );
665        assert_eq!(
666            args["notCreated"]["c1"]["type"], "forbidden",
667            "create must be forbidden: {args}"
668        );
669    }
670
671    /// Oracle: TaskList/set destroy with tasks returns taskListHasTask error
672    /// (draft-ietf-jmap-tasks-06 §3.4).
673    ///
674    /// When `onDestroyRemoveTasks` is false (default) and the task list has tasks,
675    /// the destroy should fail with a custom `taskListHasTask` error.
676    #[tokio::test]
677    async fn task_list_set_destroy_with_tasks_returns_task_list_has_task() {
678        let mut backend = MockBackend::new_with_account("acc1");
679        backend.add_task_list_with_task("acc1", "list1");
680        let backend = Arc::new(backend);
681
682        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
683        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
684
685        let req = single_call(
686            "TaskList/set",
687            json!({
688                "accountId": "acc1",
689                "destroy": ["list1"],
690                "onDestroyRemoveTasks": false
691            }),
692            "c0",
693        );
694        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
695        let (_, args, _) = &resp.method_responses[0];
696        assert!(
697            args.get("type").is_none(),
698            "must not be a top-level error: {args}"
699        );
700        assert_eq!(
701            args["notDestroyed"]["list1"]["type"], "taskListHasTask",
702            "must return taskListHasTask when list has tasks: {args}"
703        );
704    }
705
706    // ── Integration tests: isDraft, utcStart, per-user routing ──────────────
707
708    /// Oracle: draft-tasks-06 §4 — isDraft false→true revert is rejected via
709    /// dispatcher (end-to-end path through register_tasks_handlers).
710    #[tokio::test]
711    async fn isdraft_revert_via_dispatcher() {
712        let mut backend = MockBackend::new_with_account("acc1");
713        backend.seed_task("acc1", "t1", false);
714        let backend = Arc::new(backend);
715
716        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
717        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
718
719        let req = single_call(
720            "Task/set",
721            json!({
722                "accountId": "acc1",
723                "update": { "t1": { "isDraft": true } }
724            }),
725            "c0",
726        );
727        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
728        let (_, args, _) = &resp.method_responses[0];
729        assert!(
730            args.get("type").is_none(),
731            "must not be a top-level error: {args}"
732        );
733        assert_eq!(
734            args["notUpdated"]["t1"]["type"], "invalidProperties",
735            "isDraft revert must be invalidProperties: {args}"
736        );
737        assert_eq!(
738            args["notUpdated"]["t1"]["properties"][0], "isDraft",
739            "isDraft must be listed in properties: {args}"
740        );
741    }
742
743    /// Oracle: draft-tasks-06 §4 — isDraft false (draft → published) is always
744    /// allowed; the handler must not pre-reject it.
745    #[tokio::test]
746    async fn isdraft_draft_to_publish_allowed() {
747        let mut backend = MockBackend::new_with_account("acc1");
748        backend.seed_task("acc1", "t1", true);
749        let backend = Arc::new(backend);
750
751        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
752        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
753
754        let req = single_call(
755            "Task/set",
756            json!({
757                "accountId": "acc1",
758                "update": { "t1": { "isDraft": false } }
759            }),
760            "c0",
761        );
762        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
763        let (_, args, _) = &resp.method_responses[0];
764        // The patch must NOT be pre-rejected with invalidProperties.
765        if let Some(not_updated) = args["notUpdated"].as_object() {
766            if let Some(err) = not_updated.get("t1") {
767                assert_ne!(
768                    err["type"].as_str(),
769                    Some("invalidProperties"),
770                    "isDraft:false must not produce invalidProperties: {args}"
771                );
772            }
773        }
774    }
775
776    /// Oracle: draft-tasks-06 §4 (utcStart/utcDue paragraphs) — utcStart is NOT returned
777    /// when not in the properties list.
778    #[tokio::test]
779    async fn utcstart_not_in_default_properties() {
780        let backend = Arc::new(MockBackend::new_with_account("acc1"));
781        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
782        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
783
784        let req = single_call(
785            "Task/get",
786            json!({ "accountId": "acc1", "ids": null, "properties": ["id"] }),
787            "c0",
788        );
789        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
790        let (_, args, _) = &resp.method_responses[0];
791        assert!(args.get("type").is_none(), "must not be error: {args}");
792        for item in args["list"].as_array().into_iter().flatten() {
793            assert!(
794                item.get("utcStart").is_none(),
795                "utcStart must not appear when not requested: {item}"
796            );
797        }
798    }
799
800    /// Oracle: draft-tasks-06 §4 — when utcStart is explicitly requested,
801    /// the handler invokes compute_utc_times (default: returns None, so
802    /// no value injected), but no error is raised.
803    #[tokio::test]
804    async fn utcstart_returned_when_requested() {
805        let backend = Arc::new(MockBackend::new_with_account("acc1"));
806        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
807        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
808
809        let req = single_call(
810            "Task/get",
811            json!({
812                "accountId": "acc1",
813                "ids": null,
814                "properties": ["id", "utcStart"]
815            }),
816            "c0",
817        );
818        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
819        let (_, args, _) = &resp.method_responses[0];
820        assert!(
821            args.get("type").is_none(),
822            "Task/get with utcStart must not error: {args}"
823        );
824    }
825
826    /// Oracle: bd:JMAP-ops7.25 — the `account_id` argument plumbed into
827    /// `TasksBackend::compute_utc_times` matches the request's `accountId`
828    /// and is delivered as a validated [`Id`]. Locks in the args-threading
829    /// contract: a backend that overrides `compute_utc_times` can rely on
830    /// receiving the caller-validated account id.
831    #[tokio::test]
832    async fn compute_utc_times_receives_validated_account_id() {
833        use std::sync::Mutex;
834
835        use jmap_server::{
836            BackendChangesError, BackendSetError, ChangesResult, GetObject, JmapBackend,
837            JmapObject, QueryChangesResult, QueryObject, QueryResult, SetObject,
838        };
839        use jmap_tasks_types::Task;
840        use jmap_types::{Id, UTCDate};
841
842        use crate::backend::TasksBackend;
843        use crate::test_support::MockError;
844
845        /// Minimal recording backend that returns exactly one Task on
846        /// `get_objects::<Task>` (so the handler's augmentation loop fires
847        /// once) and captures the `account_id` argument that `compute_utc_times`
848        /// receives.
849        struct AccountIdRecorder {
850            observed: Mutex<Vec<Id>>,
851        }
852
853        impl JmapBackend for AccountIdRecorder {
854            type Error = MockError;
855            type CallerCtx = ();
856
857            async fn account_exists(
858                &self,
859                _caller: &(),
860                _account_id: &Id,
861            ) -> Result<bool, Self::Error> {
862                Ok(true)
863            }
864
865            async fn get_objects<O: GetObject + Send + Sync>(
866                &self,
867                _caller: &(),
868                _account_id: &Id,
869                _ids: Option<&[Id]>,
870                _properties: Option<&[String]>,
871            ) -> Result<(Vec<O>, Vec<Id>), Self::Error> {
872                // Synthesize one Task via JSON round-trip. Non-Task types
873                // get back an empty list (handler exercise here is
874                // Task/get only).
875                let item = json!({ "id": "t1" });
876                match serde_json::from_value::<O>(item) {
877                    Ok(obj) => Ok((vec![obj], vec![])),
878                    Err(_) => Ok((vec![], vec![])),
879                }
880            }
881
882            async fn get_state<O: JmapObject + Send + Sync>(
883                &self,
884                _caller: &(),
885                _account_id: &Id,
886            ) -> Result<State, Self::Error> {
887                Ok(State::from("s0"))
888            }
889
890            async fn get_changes<O: JmapObject + Send + Sync>(
891                &self,
892                _caller: &(),
893                _account_id: &Id,
894                _since_state: &State,
895                _max_changes: Option<u64>,
896            ) -> Result<ChangesResult, BackendChangesError<Self::Error>> {
897                Ok(ChangesResult::new(
898                    vec![],
899                    vec![],
900                    vec![],
901                    false,
902                    State::from("s0"),
903                ))
904            }
905
906            async fn query_objects<O: QueryObject + Send + Sync>(
907                &self,
908                _caller: &(),
909                _account_id: &Id,
910                _filter: Option<&O::Filter>,
911                _sort: Option<&[O::Comparator]>,
912                _limit: Option<u64>,
913                _position: i64,
914            ) -> Result<QueryResult, Self::Error> {
915                Ok(QueryResult::new(
916                    vec![],
917                    0,
918                    Some(0),
919                    State::from("s0"),
920                    false,
921                ))
922            }
923
924            async fn query_changes<O: QueryObject + Send + Sync>(
925                &self,
926                _caller: &(),
927                _account_id: &Id,
928                since_query_state: &State,
929                _filter: Option<&O::Filter>,
930                _sort: Option<&[O::Comparator]>,
931                _max_changes: Option<u64>,
932                _up_to_id: Option<&Id>,
933                _collapse_threads: bool,
934            ) -> Result<QueryChangesResult, BackendChangesError<Self::Error>> {
935                Ok(QueryChangesResult::new(
936                    since_query_state.clone(),
937                    State::from("s0"),
938                    Some(0),
939                    vec![],
940                    vec![],
941                ))
942            }
943        }
944
945        impl TasksBackend for AccountIdRecorder {
946            async fn create_object<O: SetObject + Send + Sync>(
947                &self,
948                _caller: &(),
949                _account_id: &Id,
950                _create_id: &str,
951                obj: O,
952            ) -> Result<(Id, O), BackendSetError<Self::Error>> {
953                Ok((Id::from("t1"), obj))
954            }
955
956            async fn update_object<O: SetObject + Send + Sync>(
957                &self,
958                _caller: &(),
959                _account_id: &Id,
960                _id: &Id,
961                _patch: O::Patch,
962            ) -> Result<Option<O>, BackendSetError<Self::Error>> {
963                Ok(None)
964            }
965
966            async fn destroy_object<O: SetObject + Send + Sync>(
967                &self,
968                _caller: &(),
969                _account_id: &Id,
970                _id: &Id,
971            ) -> Result<(), BackendSetError<Self::Error>> {
972                Ok(())
973            }
974
975            fn supports_type<O: JmapObject>(&self) -> bool {
976                true
977            }
978
979            async fn task_list_has_tasks(
980                &self,
981                _caller: &(),
982                _account_id: &Id,
983                _task_list_id: &Id,
984            ) -> Result<bool, MockError> {
985                Ok(false)
986            }
987
988            async fn compute_utc_times(
989                &self,
990                _caller: &(),
991                account_id: &Id,
992                _task: &Task,
993                _tz_hint: Option<&str>,
994            ) -> (Option<UTCDate>, Option<UTCDate>) {
995                self.observed.lock().unwrap().push(account_id.clone());
996                (None, None)
997            }
998        }
999
1000        let backend = Arc::new(AccountIdRecorder {
1001            observed: Mutex::new(Vec::new()),
1002        });
1003        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
1004        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
1005
1006        let req = single_call(
1007            "Task/get",
1008            json!({
1009                "accountId": "acc1",
1010                "ids": null,
1011                "properties": ["id", "utcStart"]
1012            }),
1013            "c0",
1014        );
1015        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
1016        let (_, args, _) = &resp.method_responses[0];
1017        assert!(
1018            args.get("type").is_none(),
1019            "Task/get with utcStart must not error: {args}"
1020        );
1021
1022        let seen = backend.observed.lock().unwrap().clone();
1023        assert_eq!(
1024            seen,
1025            vec![Id::from("acc1")],
1026            "compute_utc_times must receive the request's accountId exactly once \
1027             (one Task synthesized, utcStart requested)"
1028        );
1029    }
1030
1031    /// Oracle: draft-tasks-06 §4.5.1 — a per-user-only patch is routed to
1032    /// `update_task_per_user`. MockBackend tracks the call count.
1033    #[tokio::test]
1034    async fn per_user_patch_split() {
1035        let backend = Arc::new(MockBackend::new_with_account("acc1"));
1036        let mut dispatcher: Dispatcher<()> = Dispatcher::new();
1037        register_tasks_handlers(&mut dispatcher, Arc::clone(&backend));
1038
1039        let before = backend.per_user_calls.load(Ordering::Relaxed);
1040
1041        let req = single_call(
1042            "Task/set",
1043            json!({
1044                "accountId": "acc1",
1045                "update": { "t1": { "color": "#ff0000" } }
1046            }),
1047            "c0",
1048        );
1049        let resp = dispatcher.dispatch(req, (), State::from("s0")).await;
1050        let (_, args, _) = &resp.method_responses[0];
1051        assert!(
1052            args.get("type").is_none(),
1053            "must not be top-level error: {args}"
1054        );
1055
1056        let after = backend.per_user_calls.load(Ordering::Relaxed);
1057        assert_eq!(
1058            after - before,
1059            1,
1060            "per_user update must have been called exactly once for a color-only patch"
1061        );
1062    }
1063}