kanade_shared/ipc/notifications.rs
1//! `notifications.*` method types — paginated history + ack +
2//! push for incoming notifications.
3//!
4//! The notification lifecycle (SPEC §2.12.8 emergency example):
5//!
6//! 1. Operator publishes via backend HTTP API → backend writes to
7//! NATS `NOTIFICATIONS` JetStream.
8//! 2. Agent consumes the stream, fans out to connected clients via
9//! `notifications.new` push.
10//! 3. User clicks "確認" → client sends `notifications.ack` → agent
11//! writes `notifications_read` KV (keyed by
12//! `{pc_id}.{user_sid}.{notification_id}`) AND publishes
13//! `events.notifications.acked.{pc_id}.{user_sid}.{notification_id}`
14//! so the SPA can show per-user confirmation status.
15//! 4. Past notifications stay queryable via `notifications.list` —
16//! that's the recovery path when the agent missed a push during
17//! a network blip.
18
19use serde::{Deserialize, Serialize};
20
21// ---------- shared notification body ----------
22
23/// Notification body — used both for [`NotificationsListResult`]
24/// entries and the [`NotificationNewParams`] push.
25#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
26pub struct Notification {
27 /// Stable id minted by the backend (UUID v7). Identifies the
28 /// notification for ack / history lookups.
29 pub id: String,
30 pub priority: NotificationPriority,
31 /// Whether the user must explicitly click "確認" to dismiss.
32 /// Non-acked notifications stay pinned on the Client App's
33 /// notification panel until clicked; acked ones drop into
34 /// history.
35 #[serde(default)]
36 pub require_ack: bool,
37 pub title: String,
38 pub body: String,
39 /// Whether to surface an OS toast for this notification — decoupled
40 /// from [`priority`](Self::priority). `true` gives the full "make
41 /// sure they see it" treatment (persistent native toast; the agent
42 /// launches the Client App when it isn't running; lands in the lock
43 /// screen / Action Center; re-pops on logon/unlock). `false` shows it
44 /// only in the in-app list. `#[serde(default)]` (⇒ `false`) just so a
45 /// pre-this-field body on the retained stream still decodes — it is
46 /// NOT a priority fallback; toast behaviour is driven solely by this
47 /// flag.
48 #[serde(default)]
49 pub toast: bool,
50 /// When the notification was created (backend wall clock).
51 pub issued_at: chrono::DateTime<chrono::Utc>,
52 /// Optional human-readable label of who created the
53 /// notification (e.g. `"infra-team"` in SPEC §2.12.8). Surfaced
54 /// in the Client App for context.
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub issued_by: Option<String>,
57 /// Optional expiry (SPEC §2.4.1 `expires_at`). Past this instant
58 /// the Client App stops surfacing the notification (it drops out
59 /// of toasts / the modal / the unread badge) even if never acked.
60 /// `None` ⇒ the notification never auto-expires. Additive +
61 /// optional so pre-Phase-E bodies on the wire still decode.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
64 /// `acked_at` from this user's perspective. Populated by
65 /// `notifications.list` for already-acked entries; never set on
66 /// `notifications.new` pushes (a fresh push by definition
67 /// hasn't been acked yet).
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
70 /// When this notification was last edited (`PATCH /api/notifications/{id}`),
71 /// re-published with the same `id` + `issued_at` but new content. `None`
72 /// ⇒ never edited. Lets the SPA show an "edited" badge and lets a client
73 /// recognise a re-published copy as a content update of one it already
74 /// holds (vs a fresh arrival). Additive + optional so pre-edit bodies on
75 /// the retained stream still decode.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
78 /// When an edit reset confirmations: any ack (read mark) recorded *before*
79 /// this instant is stale and the user must re-confirm the new content.
80 /// The agent's `notifications.list` treats a read mark older than this as
81 /// unread; a connected client clears a locally-held ack older than this on
82 /// the live update. `None` ⇒ acks were never reset (the common case).
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub acks_reset_at: Option<chrono::DateTime<chrono::Utc>>,
85}
86
87/// Severity ladder. Drives the SPA color, toast/dialog choice, and
88/// whether the Client App grabs window focus on push arrival.
89/// `#[non_exhaustive]` so a future SPEC can add severities (e.g.
90/// `Critical` above Emergency) without a wire bump.
91#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
92#[serde(rename_all = "snake_case")]
93#[non_exhaustive]
94pub enum NotificationPriority {
95 /// Background-style toast. Routine maintenance reminders.
96 Info,
97 /// Yellow toast. Heads-up about upcoming changes.
98 Warn,
99 /// Red modal — grabs window focus, blocks until ack
100 /// (SPEC §2.12.8: "緊急: ネットワーク機器メンテ").
101 Emergency,
102 /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
103 /// only affects Rust match exhaustiveness — serde still hard-fails
104 /// on an unknown variant STRING, so a newer peer's new variant
105 /// used to make older readers reject the whole containing message.
106 /// Unknown decodes any unrecognised value; UIs render it neutrally.
107 #[serde(other)]
108 Unknown,
109}
110
111// ---------- notifications.list ----------
112
113/// `notifications.list` params — paginated history of notifications
114/// this user has received (per-user, scoped via OS SID).
115#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
116pub struct NotificationsListParams {
117 /// Filter: which subset of the user's notifications to return.
118 /// Defaults to [`NotificationsFilter::Unread`] — the Client App
119 /// loads the unread bucket on first paint.
120 #[serde(default)]
121 pub filter: NotificationsFilter,
122 /// Max number of entries to return. Clamped agent-side to a
123 /// safe upper bound (currently 200) so a misbehaving client
124 /// can't ask for unbounded history. Defaults to 50.
125 #[serde(default = "default_limit")]
126 pub limit: u32,
127 /// Continuation token from a prior response's
128 /// [`NotificationsListResult::next_cursor`]. `None` on first
129 /// page.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub cursor: Option<String>,
132}
133
134impl Default for NotificationsListParams {
135 fn default() -> Self {
136 Self {
137 filter: NotificationsFilter::default(),
138 limit: default_limit(),
139 cursor: None,
140 }
141 }
142}
143
144fn default_limit() -> u32 {
145 50
146}
147
148/// History-list filter selector.
149#[derive(
150 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
151)]
152#[serde(rename_all = "snake_case")]
153pub enum NotificationsFilter {
154 /// Only entries this user has NOT acked. Default — the Client
155 /// App's notification panel opens to this view.
156 #[default]
157 Unread,
158 /// Everything in the user's history window, acked or not.
159 All,
160}
161
162/// `notifications.list` response.
163#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
164pub struct NotificationsListResult {
165 pub items: Vec<Notification>,
166 /// Opaque continuation token. `Some(cursor)` ⇒ caller should
167 /// re-request with `params.cursor = Some(cursor)` to fetch the
168 /// next page; `None` ⇒ caller has the tail.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub next_cursor: Option<String>,
171}
172
173// ---------- notifications.subscribe ----------
174
175#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
176pub struct NotificationsSubscribeParams {}
177
178#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
179pub struct NotificationsSubscribeResult {
180 pub subscription: String,
181}
182
183#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
184pub struct NotificationsUnsubscribeParams {
185 pub subscription: String,
186}
187
188// ---------- notifications.new (push) ----------
189
190/// Push payload for `notifications.new`. The full notification body
191/// inline — no second round-trip needed.
192#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
193pub struct NotificationNewParams {
194 #[serde(flatten)]
195 pub notification: Notification,
196}
197
198// ---------- notifications.ack ----------
199
200/// `notifications.ack` params — mark this notification read for the
201/// caller's user (SID derived from the OS at connect time, NOT
202/// from the payload). SPEC §2.12.4 forbids ack-ing other users'
203/// notifications even on a shared PC — the agent rejects with
204/// `Unauthorized` if the notification's audience doesn't include
205/// the caller.
206#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
207pub struct NotificationsAckParams {
208 pub id: String,
209}
210
211/// `notifications.ack` response — confirms the agent persisted the
212/// ack and published the `events.notifications.acked.>` event.
213#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
214pub struct NotificationsAckResult {
215 /// Wall-clock the agent wrote into `notifications_read` KV.
216 pub acked_at: chrono::DateTime<chrono::Utc>,
217}
218
219// ---------- backend HTTP compose (POST /api/notifications) ----------
220
221/// Operator-facing request body for `POST /api/notifications` (and the
222/// equivalent `notifications/*.yaml` manifest, SPEC §2.4.1). The
223/// backend mints the [`Notification::id`] (when `id` is omitted) and
224/// [`Notification::issued_at`], resolves [`target`](Self::target) into
225/// the `notifications.{all|group.X|pc.Y}` fan-out subjects, and
226/// publishes one [`Notification`] per resolved subject into the
227/// `NOTIFICATIONS` stream.
228#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
229pub struct PublishNotificationRequest {
230 /// Operator-supplied id — the manifest's `id:` doubles as the
231 /// notification id (SPEC §2.4.1). Omit it for ad-hoc SPA composer
232 /// sends and the backend mints a UUID instead.
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub id: Option<String>,
235 pub priority: NotificationPriority,
236 #[serde(default)]
237 pub require_ack: bool,
238 pub title: String,
239 pub body: String,
240 /// Surface an OS toast (see [`Notification::toast`]). Decoupled from
241 /// `priority`; defaults to `false` (in-app only).
242 #[serde(default)]
243 pub toast: bool,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub issued_by: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
248 /// Fan-out audience — same shape as a job manifest's `target:`
249 /// (SPEC §2.4.1). At least one of `all` / `groups` / `pcs` must be
250 /// set or the backend rejects the request.
251 pub target: crate::manifest::Target,
252}
253
254/// Response of `POST /api/notifications` — the minted/echoed id plus
255/// the subjects the notification fanned out to, so the operator UI can
256/// confirm the resolved audience.
257#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
258pub struct PublishNotificationResponse {
259 pub id: String,
260 pub subjects: Vec<String>,
261}
262
263// ---------- backend HTTP edit (PATCH /api/notifications/{id}) --------
264
265/// Operator-facing request body for `PATCH /api/notifications/{id}` — edit
266/// an already-sent notification's content (fix a typo, shorten/extend the
267/// expiry, change priority / require_ack / toast) without re-sending it.
268///
269/// The **audience is immutable** here — there is no `target` field. Changing
270/// who it goes to is "recall → re-send" (the backend keeps the original
271/// fan-out subjects). `id`, `issued_at`, and `issued_by` are preserved; only
272/// the fields below change. The backend deletes the old stream copies and
273/// re-publishes the merged notification under the same id + `issued_at` (so
274/// "sent at" is unchanged), stamping [`Notification::edited_at`].
275///
276/// Unlike [`PublishNotificationRequest`] this is a *full* edit set (the SPA
277/// pre-fills every field from the current notification and submits them all),
278/// so there is no per-field optionality to disambiguate; `expires_at: None`
279/// means "never expires", a past instant expires it immediately.
280#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
281pub struct EditNotificationRequest {
282 pub priority: NotificationPriority,
283 #[serde(default)]
284 pub require_ack: bool,
285 pub title: String,
286 pub body: String,
287 #[serde(default)]
288 pub toast: bool,
289 /// `None` ⇒ never expires; a past instant expires it immediately (unlike
290 /// `publish`, which rejects a past expiry as a likely typo — here it is a
291 /// deliberate "retire it but keep history" choice, distinct from recall).
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
294 /// Reset confirmations: when `true` the backend clears every recorded ack
295 /// for this notification and stamps [`Notification::acks_reset_at`], so a
296 /// materially-changed body forces everyone to re-confirm. `false` (the
297 /// default, e.g. a typo fix) leaves existing confirmations intact.
298 #[serde(default)]
299 pub reset_acks: bool,
300}
301
302// ---------- ack event (Agent → NATS → backend projector) ----------
303
304/// Body of the
305/// `events.notifications.acked.{pc_id}.{user_sid}.{notif_id}` event the
306/// agent publishes when a user acks a notification. The backend's
307/// notification-acks projector reads these fields from the JSON body
308/// (not by parsing the subject) so an id / SID containing a `.` can't
309/// desync the projected row from its subject tokens.
310#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
311pub struct NotificationAcked {
312 pub notification_id: String,
313 pub pc_id: String,
314 pub user_sid: String,
315 pub acked_at: chrono::DateTime<chrono::Utc>,
316 /// The acking user's login name (`DOMAIN\sam` or `.\user`), from the
317 /// agent connection's resolved peer identity — far more legible than
318 /// the raw SID in the operator's confirmation view. Additive +
319 /// optional so a pre-this-version agent's ack (SID only) still
320 /// decodes; the backend falls back to the PC's last-logon identity
321 /// when it's absent.
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub account: Option<String>,
324}
325
326// ---------- ack status (GET /api/notifications/{id}/ack_status) ----
327
328/// One recipient's confirmation record for a notification.
329#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
330pub struct NotificationAckEntry {
331 pub pc_id: String,
332 pub user_sid: String,
333 pub acked_at: chrono::DateTime<chrono::Utc>,
334 /// Human-readable label for who confirmed — the acking user's login
335 /// name from the ack event, or (for pre-account acks) the PC's
336 /// last-logon display name / login as a fallback. `None` only when
337 /// neither is available, in which case the SPA shows the `user_sid`.
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub account: Option<String>,
340}
341
342/// Response of `GET /api/notifications/{id}/ack_status` — every
343/// `(pc_id, user_sid, acked_at)` tuple recorded for the notification,
344/// powering the SPA's "who confirmed when" view.
345#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
346pub struct NotificationAckStatus {
347 pub id: String,
348 pub acks: Vec<NotificationAckEntry>,
349}
350
351// ---------- detail (GET /api/notifications/{id}) ------------------
352
353/// Response of `GET /api/notifications/{id}` — one sent notification's
354/// full content (so the SPA can show "what was sent", including the
355/// `body` the history table truncates away) paired with its
356/// per-recipient confirmation list. Powers the deep-linkable
357/// `/notifications/{id}` detail page, which an operator opens in a new
358/// tab from the history list (Ctrl/⌘ click), mirroring the Activity →
359/// result-detail deep link.
360///
361/// `acks` is the same set `ack_status` returns; bundling it here saves
362/// the detail page a second round-trip.
363///
364/// `audience` is the per-PC confirmation roster (④): the set of PCs the
365/// notification was addressed to, each flagged confirmed/pending, so an
366/// operator can see *who hasn't* acknowledged — not just who has. Empty
367/// when the audience couldn't be reconstructed (e.g. the fan-out subjects
368/// aged out of the stream).
369#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
370pub struct NotificationDetail {
371 pub notification: Notification,
372 pub acks: Vec<NotificationAckEntry>,
373 #[serde(default)]
374 pub audience: Vec<AudiencePc>,
375 /// The original send target (where it was addressed: all / groups /
376 /// pcs), reconstructed from the fan-out subjects — so the SPA can show
377 /// "送信先" (vs `audience`, which is the *resolved* per-PC roster).
378 /// `None` when the subjects couldn't be reconstructed.
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub target: Option<NotificationTarget>,
381}
382
383/// The audience a notification was *addressed* to (the `target:` of the
384/// publish), reconstructed from its fan-out subjects
385/// (`notifications.{all|group.X|pc.Y}`). Distinct from the resolved
386/// per-PC [`AudiencePc`] roster: this is the operator's intent ("sent to
387/// the it-admins group + PC minipc"), not the expanded PC list.
388#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
389pub struct NotificationTarget {
390 #[serde(default)]
391 pub all: bool,
392 #[serde(default)]
393 pub groups: Vec<String>,
394 #[serde(default)]
395 pub pcs: Vec<String>,
396}
397
398/// One targeted PC's confirmation state, for the detail page's "who
399/// hasn't confirmed" roster (④). Resolved by expanding the notification's
400/// fan-out subjects (`all` / `group.X` / `pc.Y`) to the fleet's PCs and
401/// joining against the recorded acks.
402///
403/// Granularity is the PC, not the individual user: the backend has no
404/// full per-PC user roster, only each host's last-logon identity, so
405/// `last_logon_*` stands in as "the PC's representative user". `confirmed`
406/// is true when *any* user on that PC acked (the detailed who-and-when is
407/// in `acks`).
408#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
409pub struct AudiencePc {
410 pub pc_id: String,
411 /// The host's last sign-in account (`DOMAIN\sam`) / display name from
412 /// the `agents` row — `None` for a targeted PC with no agent record
413 /// (e.g. an explicit `pc.Y` target that never registered).
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub last_logon_user: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub last_logon_display_name: Option<String>,
418 pub confirmed: bool,
419 /// Earliest ack instant recorded for this PC; `None` while pending.
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub acked_at: Option<chrono::DateTime<chrono::Utc>>,
422}
423
424// ---------- amend (post-send operations) -------------------------
425
426/// A post-send amendment to an already-fanned-out notification, broadcast
427/// fleet-wide on the ephemeral [`crate::subject::NOTIFICATIONS_AMEND_SUBJECT`]
428/// channel so every connected client showing the notification can react in
429/// real time. Carries only the notification `id` plus the operation — a
430/// client applies it only if it currently holds that id (an id it never
431/// received is a no-op), so the single broadcast needs no audience routing.
432///
433/// The durable half of an operation lives in the backend (recall deletes the
434/// stream copies; a future edit re-publishes them); this is the "update the
435/// screens that are showing it right now" half. Built to grow: today only
436/// `Recall`, but `op` is a tagged enum so an `Update`/`SetExpiry` variant can
437/// be added without breaking the wire format.
438#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
439pub struct NotificationAmend {
440 pub id: String,
441 pub op: NotificationAmendOp,
442}
443
444/// The operation an [`NotificationAmend`] applies. Tagged on `kind` so future
445/// data-carrying variants (e.g. `Update { notification }`) stay wire-compatible.
446#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448pub enum NotificationAmendOp {
449 /// The notification was recalled (deleted): remove it from the panel,
450 /// unread badge, and any open require-ack modal.
451 Recall,
452}
453
454/// Params of the `notifications.amended` push (Agent → Client) — the
455/// flattened [`NotificationAmend`] (`{ "id", "kind": "recall" }`).
456#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
457pub struct NotificationAmendedParams {
458 #[serde(flatten)]
459 pub amend: NotificationAmend,
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use chrono::TimeZone;
466
467 #[test]
468 fn priority_serialises_snake_case() {
469 for (variant, expected) in [
470 (NotificationPriority::Info, "\"info\""),
471 (NotificationPriority::Warn, "\"warn\""),
472 (NotificationPriority::Emergency, "\"emergency\""),
473 ] {
474 let s = serde_json::to_string(&variant).unwrap();
475 assert_eq!(s, expected, "encode {variant:?}");
476 let back: NotificationPriority = serde_json::from_str(expected).unwrap();
477 assert_eq!(back, variant, "round-trip {expected}");
478 }
479 }
480
481 #[test]
482 fn filter_defaults_to_unread() {
483 // The Client App's notification panel opens to "unread" so
484 // the default selector must match.
485 let p = NotificationsListParams::default();
486 assert_eq!(p.filter, NotificationsFilter::Unread);
487 // Default decode of an empty object.
488 let p: NotificationsListParams = serde_json::from_str("{}").unwrap();
489 assert_eq!(p.filter, NotificationsFilter::Unread);
490 assert_eq!(p.limit, 50);
491 }
492
493 #[test]
494 fn notification_new_spec_example_decodes() {
495 // SPEC §2.12.8's emergency push payload, verbatim. The
496 // flatten attribute means the wire is the Notification's
497 // own keys at the top level — no `notification: {…}` nest.
498 let wire = r#"{
499 "id":"notif-9f3a","priority":"emergency","require_ack":true,
500 "title":"緊急: ネットワーク機器メンテ","body":"22時から30分停止します",
501 "issued_at":"2026-05-20T12:00:00Z","issued_by":"infra-team"
502 }"#;
503 let p: NotificationNewParams = serde_json::from_str(wire).expect("decode");
504 assert_eq!(p.notification.id, "notif-9f3a");
505 assert_eq!(p.notification.priority, NotificationPriority::Emergency);
506 assert!(p.notification.require_ack);
507 assert_eq!(p.notification.title, "緊急: ネットワーク機器メンテ");
508 assert_eq!(p.notification.issued_by.as_deref(), Some("infra-team"));
509 }
510
511 #[test]
512 fn notification_expires_at_is_optional_and_skipped_when_none() {
513 // Additive field: a body without expires_at decodes (None) and
514 // a None value is omitted from the wire so pre-Phase-E
515 // consumers don't see a null key.
516 let wire = r#"{
517 "id":"n1","priority":"info","title":"t","body":"b",
518 "issued_at":"2026-05-20T12:00:00Z"
519 }"#;
520 let n: Notification = serde_json::from_str(wire).expect("decode without expires_at");
521 assert!(n.expires_at.is_none());
522 let v = serde_json::to_value(&n).unwrap();
523 assert!(
524 v.get("expires_at").is_none(),
525 "None expires_at omitted: {v:?}"
526 );
527 }
528
529 #[test]
530 fn notification_toast_defaults_false_and_round_trips() {
531 // A body on the retained stream from before the `toast` field
532 // decodes with toast = false (so old messages just don't toast).
533 let wire = r#"{
534 "id":"n1","priority":"info","title":"t","body":"b",
535 "issued_at":"2026-05-20T12:00:00Z"
536 }"#;
537 let n: Notification = serde_json::from_str(wire).expect("decode without toast");
538 assert!(!n.toast, "absent toast ⇒ false (in-app only, not a toast)");
539
540 // And an explicit toast:true round-trips.
541 let wire_true = r#"{
542 "id":"n2","priority":"warn","title":"t","body":"b","toast":true,
543 "issued_at":"2026-05-20T12:00:00Z"
544 }"#;
545 let n: Notification = serde_json::from_str(wire_true).expect("decode toast:true");
546 assert!(n.toast);
547 // Decoupled from priority: a warn can carry toast:true.
548 assert_eq!(n.priority, NotificationPriority::Warn);
549 }
550
551 #[test]
552 fn publish_request_toast_defaults_false_and_decodes() {
553 // Toast is driven ONLY by this flag (decoupled from priority by
554 // design): an omitted `toast` decodes to false even for an
555 // emergency — the caller must opt in with `toast: true`. There is
556 // deliberately no priority fallback.
557 let req: PublishNotificationRequest =
558 serde_json::from_str(r#"{"priority":"emergency","title":"t","body":"b","target":{}}"#)
559 .expect("decode without toast");
560 assert!(!req.toast, "omitted toast ⇒ false, even for emergency");
561
562 let req: PublishNotificationRequest = serde_json::from_str(
563 r#"{"priority":"warn","title":"t","body":"b","toast":true,"target":{}}"#,
564 )
565 .expect("decode with toast:true");
566 assert!(req.toast, "explicit toast:true on a non-emergency priority");
567 }
568
569 #[test]
570 fn publish_request_requires_target_audience() {
571 // The wire decodes a target with no audience set; the handler
572 // is what rejects it. Here we just pin Target::is_specified so
573 // the handler's guard has a stable contract to lean on.
574 let req: PublishNotificationRequest =
575 serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b","target":{}}"#)
576 .expect("decode");
577 assert!(!req.target.is_specified(), "empty target is unspecified");
578 assert_eq!(req.id, None, "id omitted ⇒ backend mints one");
579 assert!(!req.require_ack, "require_ack defaults false");
580 assert!(!req.toast, "toast defaults false");
581 }
582
583 #[test]
584 fn edit_request_decodes_with_defaults() {
585 // Minimal body: the SPA always submits all editable fields, but
586 // require_ack / toast / reset_acks default false and expires_at omitted
587 // ⇒ never expires.
588 let req: EditNotificationRequest =
589 serde_json::from_str(r#"{"priority":"warn","title":"t","body":"b"}"#).expect("decode");
590 assert!(!req.require_ack);
591 assert!(!req.toast);
592 assert!(
593 !req.reset_acks,
594 "reset_acks defaults false (keep confirmations)"
595 );
596 assert_eq!(req.expires_at, None, "omitted expiry ⇒ never expires");
597
598 // reset_acks + an explicit expiry decode as set.
599 let req: EditNotificationRequest = serde_json::from_str(
600 r#"{"priority":"info","title":"t","body":"b","reset_acks":true,"expires_at":"2099-01-01T00:00:00Z"}"#,
601 )
602 .expect("decode");
603 assert!(req.reset_acks);
604 assert!(req.expires_at.is_some());
605 }
606
607 #[test]
608 fn notification_edit_fields_default_none_and_round_trip() {
609 // A pre-edit body (no edited_at / acks_reset_at) still decodes, and
610 // both fields are omitted on the wire when None.
611 let n: Notification = serde_json::from_str(
612 r#"{"id":"n1","priority":"info","title":"t","body":"b","issued_at":"2026-06-01T00:00:00Z"}"#,
613 )
614 .expect("decode pre-edit body");
615 assert_eq!(n.edited_at, None);
616 assert_eq!(n.acks_reset_at, None);
617 let v = serde_json::to_value(&n).unwrap();
618 assert!(
619 v.get("edited_at").is_none(),
620 "None edited_at omitted: {v:?}"
621 );
622 assert!(
623 v.get("acks_reset_at").is_none(),
624 "None acks_reset_at omitted: {v:?}"
625 );
626 }
627
628 #[test]
629 fn notification_acked_round_trips() {
630 let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
631 let a = NotificationAcked {
632 notification_id: "notif-9f3a".into(),
633 pc_id: "PC1234".into(),
634 // SIDs use hyphens, never dots — safe alongside the dotted
635 // subject, but the projector reads this body field anyway.
636 user_sid: "S-1-5-21-1001".into(),
637 acked_at: t,
638 account: Some("EXAMPLE\\taro".into()),
639 };
640 let json = serde_json::to_string(&a).unwrap();
641 let back: NotificationAcked = serde_json::from_str(&json).unwrap();
642 assert_eq!(back.notification_id, a.notification_id);
643 assert_eq!(back.pc_id, a.pc_id);
644 assert_eq!(back.user_sid, a.user_sid);
645 assert_eq!(back.acked_at, t);
646 assert_eq!(back.account.as_deref(), Some("EXAMPLE\\taro"));
647 }
648
649 #[test]
650 fn notification_amend_recall_round_trips() {
651 // Wire shape the backend broadcasts and the client decodes:
652 // the op is tagged on `kind` so adding a data-carrying variant
653 // later (Update { .. }) stays compatible.
654 let a = NotificationAmend {
655 id: "notif-9f3a".into(),
656 op: NotificationAmendOp::Recall,
657 };
658 let v = serde_json::to_value(&a).unwrap();
659 assert_eq!(v["id"], "notif-9f3a");
660 assert_eq!(v["op"]["kind"], "recall");
661 let back: NotificationAmend = serde_json::from_value(v).unwrap();
662 assert_eq!(back, a);
663
664 // The push params flatten the amend (no nested "amend" key).
665 let p = NotificationAmendedParams { amend: a.clone() };
666 let pv = serde_json::to_value(&p).unwrap();
667 assert_eq!(pv["id"], "notif-9f3a");
668 assert_eq!(pv["op"]["kind"], "recall");
669 assert!(pv.get("amend").is_none(), "amend is flattened: {pv:?}");
670 }
671
672 #[test]
673 fn notification_acked_without_account_decodes() {
674 // A pre-account agent emits the ack body without `account`; it must
675 // still decode (None), and a None account is omitted on the wire so
676 // older readers never see a null key.
677 let wire = r#"{
678 "notification_id":"n1","pc_id":"PC1","user_sid":"S-1-5-21-1",
679 "acked_at":"2026-05-20T12:00:05Z"
680 }"#;
681 let a: NotificationAcked = serde_json::from_str(wire).expect("decode without account");
682 assert_eq!(a.account, None);
683 let v = serde_json::to_value(&a).unwrap();
684 assert!(v.get("account").is_none(), "None account omitted: {v:?}");
685 }
686
687 #[test]
688 fn ack_result_round_trips() {
689 let t = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 5).unwrap();
690 let r = NotificationsAckResult { acked_at: t };
691 let json = serde_json::to_string(&r).unwrap();
692 let back: NotificationsAckResult = serde_json::from_str(&json).unwrap();
693 assert_eq!(back.acked_at, t);
694 }
695
696 #[test]
697 fn notifications_list_paginates_via_cursor() {
698 // First page: no cursor.
699 let p = NotificationsListParams {
700 filter: NotificationsFilter::All,
701 limit: 25,
702 cursor: None,
703 };
704 let v = serde_json::to_value(&p).unwrap();
705 assert!(v.get("cursor").is_none(), "wire: {v:?}");
706
707 // Continuation: cursor present.
708 let p = NotificationsListParams {
709 cursor: Some("opaque-token".into()),
710 ..NotificationsListParams::default()
711 };
712 let v = serde_json::to_value(&p).unwrap();
713 assert_eq!(v["cursor"], "opaque-token");
714 }
715}