jmap_tasks_client/methods/task_notification.rs
1//! JMAP Tasks — TaskNotification/* method implementations on SessionClient.
2//!
3//! TaskNotification/set is destroy-only. Any create or update entries from
4//! the client would be rejected by the server with `forbidden`, but we
5//! expose a typed API that only allows destroy to avoid client-side confusion.
6
7use jmap_types::{Id, State};
8
9use super::{ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse};
10
11impl super::SessionClient {
12 /// Fetch TaskNotification objects by IDs (draft-tasks-06 §5.2).
13 ///
14 /// If `ids` is `None`, the server returns all TaskNotifications for the account,
15 /// SUBJECT TO the server's `maxObjectsInGet` cap (RFC 8620 §5.1).
16 /// For production use, scope the result set via the corresponding
17 /// /query method first and pass explicit ids here to avoid
18 /// `requestTooLarge` errors when the account holds more objects
19 /// than the cap.
20 ///
21 /// # Errors
22 ///
23 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
24 /// if the bound session has no primary account for
25 /// `urn:ietf:params:jmap:tasks`.
26 /// - Any transport / protocol variant returned by
27 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
28 /// [`Http`](jmap_base_client::ClientError::Http),
29 /// [`Parse`](jmap_base_client::ClientError::Parse),
30 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
31 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
32 /// (wraps RFC 8620 §3.6.2 method-level errors such as
33 /// `accountNotFound`, `invalidArguments`, `serverFail`),
34 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
35 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
36 /// or
37 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
38 pub async fn task_notification_get(
39 &self,
40 ids: Option<&[Id]>,
41 properties: Option<&[&str]>,
42 ) -> Result<GetResponse<jmap_tasks_types::TaskNotification>, jmap_base_client::ClientError>
43 {
44 let (api_url, account_id) = self.session_parts()?;
45 // Omit `ids` / `properties` when None — see the matching comment on
46 // `task_list_get` for the rationale (consistent with set/changes/query).
47 let mut args = serde_json::json!({ "accountId": account_id });
48 if let Some(id_slice) = ids {
49 args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
50 }
51 if let Some(props) = properties {
52 args["properties"] =
53 serde_json::to_value(props).expect("&[&str] Serialize is infallible");
54 }
55 let req = super::build_request("TaskNotification/get", args, super::USING_TASKS);
56 let resp = self.call_internal(api_url, &req).await?;
57 jmap_base_client::extract_response(&resp, super::CALL_ID)
58 }
59
60 /// Fetch changes to TaskNotification objects since `since_state`
61 /// (draft-tasks-06 §5.3).
62 ///
63 /// # Errors
64 ///
65 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
66 /// if `since_state` is the empty string (defence-in-depth —
67 /// `State` constructed via [`State::from`](jmap_types::State::from)
68 /// accepts empty strings, but an empty `sinceState` is never
69 /// useful and would otherwise generate a wasted round-trip).
70 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
71 /// if the bound session has no primary account for
72 /// `urn:ietf:params:jmap:tasks`.
73 /// - Any transport / protocol variant returned by
74 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
75 /// the matching error list on [`Self::task_notification_get`].
76 pub async fn task_notification_changes(
77 &self,
78 since_state: &State,
79 max_changes: Option<u64>,
80 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
81 // Defence-in-depth: see task_list_changes.
82 if since_state.as_ref().is_empty() {
83 return Err(jmap_base_client::ClientError::InvalidArgument(
84 "task_notification_changes: since_state may not be empty".into(),
85 ));
86 }
87 let (api_url, account_id) = self.session_parts()?;
88 let mut args = serde_json::json!({
89 "accountId": account_id,
90 "sinceState": since_state,
91 });
92 if let Some(mc) = max_changes {
93 args["maxChanges"] = mc.into();
94 }
95 let req = super::build_request("TaskNotification/changes", args, super::USING_TASKS);
96 let resp = self.call_internal(api_url, &req).await?;
97 jmap_base_client::extract_response(&resp, super::CALL_ID)
98 }
99
100 /// Destroy TaskNotification objects (draft-tasks-06 §5.4).
101 ///
102 /// **Destroy-only**: TaskNotification/set only supports `destroy`.
103 /// The server creates notifications automatically; clients may only remove them.
104 ///
105 /// Passing an empty `destroy` list is valid and produces an empty /set response.
106 ///
107 /// # Errors
108 ///
109 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
110 /// if the bound session has no primary account for
111 /// `urn:ietf:params:jmap:tasks`.
112 /// - Any transport / protocol variant returned by
113 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
114 /// the matching error list on [`Self::task_notification_get`].
115 pub async fn task_notification_set(
116 &self,
117 destroy: Vec<Id>,
118 ) -> Result<SetResponse, jmap_base_client::ClientError> {
119 let (api_url, account_id) = self.session_parts()?;
120 let args = serde_json::json!({
121 "accountId": account_id,
122 "destroy": destroy,
123 });
124 let req = super::build_request("TaskNotification/set", args, super::USING_TASKS);
125 let resp = self.call_internal(api_url, &req).await?;
126 jmap_base_client::extract_response(&resp, super::CALL_ID)
127 }
128
129 /// Query TaskNotification IDs with optional filter and sort
130 /// (draft-tasks-06 §5.5).
131 ///
132 /// # Errors
133 ///
134 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
135 /// if the bound session has no primary account for
136 /// `urn:ietf:params:jmap:tasks`.
137 /// - Any transport / protocol variant returned by
138 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
139 /// the matching error list on [`Self::task_notification_get`].
140 /// RFC 8620 §5.5 defines additional /query method-level errors
141 /// (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
142 /// `tooManyChanges`) that surface as
143 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
144 pub async fn task_notification_query(
145 &self,
146 filter: Option<serde_json::Value>,
147 sort: Option<serde_json::Value>,
148 position: Option<u64>,
149 limit: Option<u64>,
150 ) -> Result<QueryResponse, jmap_base_client::ClientError> {
151 let (api_url, account_id) = self.session_parts()?;
152 let mut args = serde_json::json!({
153 "accountId": account_id,
154 });
155 if let Some(f) = filter {
156 args["filter"] = f;
157 }
158 if let Some(s) = sort {
159 args["sort"] = s;
160 }
161 if let Some(p) = position {
162 args["position"] = p.into();
163 }
164 if let Some(l) = limit {
165 args["limit"] = l.into();
166 }
167 let req = super::build_request("TaskNotification/query", args, super::USING_TASKS);
168 let resp = self.call_internal(api_url, &req).await?;
169 jmap_base_client::extract_response(&resp, super::CALL_ID)
170 }
171
172 /// Fetch query-result changes for TaskNotification since `since_query_state`
173 /// (draft-tasks-06 §5.6).
174 ///
175 /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
176 /// original `TaskNotification/query` call that returned
177 /// `since_query_state` — RFC 8620 §5.6 is explicit that the server uses
178 /// them to compute which entries entered or left the result set.
179 ///
180 /// `up_to_id` is the highest-index id the client has cached;
181 /// `calculate_total` requests the new total result count.
182 ///
183 /// # Errors
184 ///
185 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
186 /// if `since_query_state` is the empty string (defence-in-depth
187 /// empty-state guard; see [`Self::task_notification_changes`]).
188 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
189 /// if the bound session has no primary account for
190 /// `urn:ietf:params:jmap:tasks`.
191 /// - Any transport / protocol variant returned by
192 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
193 /// the matching error list on [`Self::task_notification_get`].
194 /// RFC 8620 §5.6 also defines `cannotCalculateChanges` (returned
195 /// when the server cannot honour the request given the supplied
196 /// filter / sort); it surfaces as
197 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
198 pub async fn task_notification_query_changes(
199 &self,
200 since_query_state: &State,
201 max_changes: Option<u64>,
202 filter: Option<serde_json::Value>,
203 sort: Option<serde_json::Value>,
204 up_to_id: Option<&Id>,
205 calculate_total: Option<bool>,
206 ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
207 // Defence-in-depth: see task_list_changes.
208 if since_query_state.as_ref().is_empty() {
209 return Err(jmap_base_client::ClientError::InvalidArgument(
210 "task_notification_query_changes: since_query_state may not be empty".into(),
211 ));
212 }
213 let (api_url, account_id) = self.session_parts()?;
214 let mut args = serde_json::json!({
215 "accountId": account_id,
216 "sinceQueryState": since_query_state,
217 });
218 if let Some(f) = filter {
219 args["filter"] = f;
220 }
221 if let Some(s) = sort {
222 args["sort"] = s;
223 }
224 if let Some(mc) = max_changes {
225 args["maxChanges"] = mc.into();
226 }
227 if let Some(uti) = up_to_id {
228 args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
229 }
230 if let Some(ct) = calculate_total {
231 args["calculateTotal"] = ct.into();
232 }
233 let req = super::build_request("TaskNotification/queryChanges", args, super::USING_TASKS);
234 let resp = self.call_internal(api_url, &req).await?;
235 jmap_base_client::extract_response(&resp, super::CALL_ID)
236 }
237}
238
239// ---------------------------------------------------------------------------
240// Tests — see tests/task_notification_tests.rs (wiremock-backed end-to-end)
241// ---------------------------------------------------------------------------
242//
243// `task_notification_set_destroy_only_serialization` and
244// `task_notification_set_uses_tasks_capability` were vacuous: they
245// hand-built `args` Values and fed them to `build_request`, never
246// exercising the production `task_notification_set` builder. Deleted in
247// JMAP-tco1.20.
248//
249// Real production-path coverage:
250// - task_notification_get_round_trip
251// - task_notification_changes_round_trip
252// - task_notification_set_destroy_only_wire_format
253// - task_notification_set_empty_destroy_succeeds
254// - task_notification_query_with_filter
255// - task_notification_query_changes_round_trip
256// in tests/task_notification_tests.rs.
257//
258// Specific-flag passthrough coverage that may be lost is tracked
259// under JMAP-uuoi for follow-up wiremock smoke tests.
260//
261// `build_request`, `CALL_ID`, and `USING_TASKS` themselves have their
262// own focused tests in `methods/mod.rs`.
263//
264// The InvalidArgument guard for empty since_state lives in
265// task_notification_changes production code; testing it requires a
266// wiremock-backed async harness. See JMAP-sc1b.64.