//! Message-bus port — the seam every service publishes through and every WS //! session subscribes on. //! //! # Design (see `docs/plan/message-bus.md`) //! //! - [`MessageBus`] is the **local-facing** trait: services publish, the WS //! handler subscribes. It never involves the network. //! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to //! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only //! [`MessageBus`]; a real replicator plugs into the in-process impl without //! touching consumers. Day-1 impl is [`NoopReplicator`]. //! //! # MVP scope //! //! Ships the smallest slice that lets the smoke test verify a folder //! subscription receives file/folder-created events and rejects subscribes //! to folders the caller can't `Read`: //! //! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`] //! - Events: [`MessageBusEvent::FileCreated`], [`MessageBusEvent::FileRenamed`], //! [`MessageBusEvent::FileMoved`], [`MessageBusEvent::FileDeleted`], //! [`MessageBusEvent::FolderCreated`], [`MessageBusEvent::FolderRenamed`], //! [`MessageBusEvent::FolderMoved`], [`MessageBusEvent::FolderDeleted`] //! //! Adding a variant is a one-line change plus a match arm in `to_wire_key` / //! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`, //! `collab:{id}`, `user:{u}:notifications`, …) land with their producers in //! Phase-A follow-ups. //! //! # Wire protocol //! //! JSON-RPC 2.0 for control + events (text frames), Yjs sync protocol for //! CRDT (binary frames). This module owns the JSON-RPC error-code //! vocabulary; see [`error_code`]. use std::pin::Pin; use std::sync::Arc; use futures::Stream; use serde::{Deserialize, Serialize}; use tokio::sync::Notify; use uuid::Uuid; use crate::common::errors::DomainError; // ════════════════════════════════════════════════════════════════════════════ // Topic — a typed key on the bus // ════════════════════════════════════════════════════════════════════════════ /// A topic on the message bus. Typed enum, not a string — prevents typos /// and gives exhaustive matching in the AuthZ dispatch and the wire encoder. /// /// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ /// topic-exchange routing keys or NATS subjects when the [`BusReplicator`] /// seam is filled in later. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum Topic { /// A folder's mutation stream — file/subfolder created/deleted/renamed/ /// moved in or out. Consumed by the folder view for live refresh. Folder(Uuid), /// A user's private authz-change channel. The WS handler will auto- /// subscribe the caller and evict stale subs when its events fire once /// the eviction wiring lands (Phase-A follow-up). UserAuthz(Uuid), /// A user's private notifications channel — poked when a /// [`MessageBusEvent::NotificationReceived`] event fires. The WS /// handler auto-subscribes each session at session open (same /// pattern as [`Topic::UserAuthz`]). Payload is a thin fact /// (`notification_id` + `kind`); the client refetches the row from /// `GET /api/notifications` for the details. AuthZ: **strict /// identity match** — no admin bypass, direct UUID equality, /// anti-enumeration parity with [`Topic::UserAuthz`]. UserNotifications(Uuid), /// A named background job's run lifecycle — start / progress / /// end. Consumed by the admin job dashboard so operators who /// trigger a long-running job (backend migration, thumb import…) /// can navigate to other admin pages without losing progress /// visibility. AuthZ: **admin-only** (Class 3 role-scoped). /// Non-admins get `topic_forbidden` — indistinguishable on the /// wire from an unknown topic. Job names are stable /// scheduler-registered strings (e.g. `backend_migration`, /// `thumb_derived_import`); the topic string is `job:`. Job(String), } impl Topic { /// Stable dotted wire form used by the JSON-RPC control frames and any /// future broker routing keys. Reverse of [`Topic::parse`]. pub fn to_wire_key(&self) -> String { match self { Topic::Folder(id) => format!("folder:{id}"), Topic::UserAuthz(id) => format!("user:{id}:authz"), Topic::UserNotifications(id) => format!("user:{id}:notifications"), Topic::Job(name) => format!("job:{name}"), } } /// Parse a wire-form topic string. Rejects unknown shapes with a stable /// error kind so the WS handler can respond with a JSON-RPC error object /// (`topic_forbidden` for unknown topic shapes, `no_read` for known /// shapes the caller can't reach — the latter after the AuthZ check). pub fn parse(s: &str) -> Result { if let Some(rest) = s.strip_prefix("folder:") { let id = Uuid::parse_str(rest).map_err(|_| ParseTopicErr::BadUuid)?; return Ok(Topic::Folder(id)); } if let Some(rest) = s.strip_prefix("user:") && let Some((id_str, suffix)) = rest.rsplit_once(':') { let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?; return match suffix { "authz" => Ok(Topic::UserAuthz(id)), "notifications" => Ok(Topic::UserNotifications(id)), _ => Err(ParseTopicErr::Unknown), }; } if let Some(name) = s.strip_prefix("job:") { // Job names are scheduler-registered short slugs — see // `infrastructure/scheduler/registry.rs`. Validate here // only that the name is non-empty and consists of // `[a-z0-9_-]` chars — reject anything else as // `Unknown` (indistinguishable to the caller from a // topic shape we've never heard of). if !name.is_empty() && name .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') { return Ok(Topic::Job(name.to_string())); } return Err(ParseTopicErr::Unknown); } Err(ParseTopicErr::Unknown) } /// Which permission check the WS handler must run before allowing a /// subscribe. Three classes per plan (see /// `docs/plan/message-bus.md § AuthZ model`): /// /// - Resource-scoped: default `Read` on the resource (Phase-B adds /// `Share`/`Comment` for the stricter topics). /// - Identity-scoped: `caller_id == subject_uuid`. No admin bypass. /// - Role-scoped / bespoke: not represented in this MVP. pub fn required_perm(&self) -> AuthzCheck { match self { Topic::Folder(id) => AuthzCheck::ResourceRead { resource: BusResource::Folder(*id), }, Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id }, Topic::UserNotifications(id) => AuthzCheck::IdentityMatch { user_id: *id }, Topic::Job(_) => AuthzCheck::RoleAdmin, } } } /// Parse failure for a wire-form topic string. Kept small — the WS handler /// maps every variant to `topic_forbidden` on the wire (both a bad UUID and /// an unknown shape are indistinguishable from the caller's perspective). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParseTopicErr { /// The prefix was recognized but the UUID inside didn't parse. BadUuid, /// The topic string didn't match any known shape (typo, or a topic /// that isn't in this MVP). Unknown, } // ════════════════════════════════════════════════════════════════════════════ // AuthzCheck — the gate class the WS handler dispatches on // ════════════════════════════════════════════════════════════════════════════ /// Resource kinds the bus knows how to gate on. Deliberately a small closed /// enum, not the full `domain::authorization::Resource` — the bus does not /// need every resource type in the domain, and keeping this separate avoids /// dragging domain-shaped churn into the port. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BusResource { Folder(Uuid), // File(Uuid), Drive(Uuid), Calendar(Uuid), AddressBook(Uuid) land with // their topic variants. } /// The check the WS handler must run at subscribe time. Split into the three /// classes described in `docs/plan/message-bus.md § AuthZ model`, so a new /// topic variant with a new gate shape is a compile error at the dispatch /// site rather than a runtime "unhandled" bug. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AuthzCheck { /// Class 1 — Resource-scoped, default gate is Read on the resource. /// Extend to `ResourceShare`/`ResourceComment` when the Phase-B topics /// (`file:{id}:shares`, `file:{id}:comments`) land. ResourceRead { resource: BusResource }, /// Class 2 — Identity-scoped. `caller_id` must equal `user_id`. /// No admin bypass — privacy is a hard rule. IdentityMatch { user_id: Uuid }, /// Class 3 — Role-scoped. Caller must hold the admin role. Used /// by `Topic::Job(_)` today; future `admin:*` topics land here. /// Non-admin subscriber gets `topic_forbidden` on the wire — /// same anti-enum shape as unknown-topic denial. RoleAdmin, } // ════════════════════════════════════════════════════════════════════════════ // MessageBusEvent — the payload // ════════════════════════════════════════════════════════════════════════════ /// A fact that has just become true. Emitted by services AFTER commit, /// never inside a DB transaction — a rollback would otherwise fan out a /// lie. /// /// Payloads are **thin facts** (ids + actor + verb): the client refetches /// details via REST when it needs them. This keeps the AuthZ surface small /// (thin payloads can't leak fields the caller couldn't already read via /// REST) and keeps events well under the ~8 KB pg NOTIFY cap when the /// `PgListenReplicator` seam is filled in later. /// /// Wire form uses `#[serde(tag = "event", rename_all = "snake_case")]`; /// discriminator strings are the JSON-RPC notification `event` field. New /// denial cause / new event = new variant, never repurpose an existing one, /// per project convention. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "event", rename_all = "snake_case")] pub enum MessageBusEvent { /// A file was created inside `parent_id`. FileCreated { file_id: Uuid, name: String, parent_id: Uuid, actor: Uuid, }, /// A file was renamed. `parent_id` unchanged — same folder. FileRenamed { file_id: Uuid, old_name: String, new_name: String, parent_id: Uuid, actor: Uuid, }, /// A file was moved between folders. Fanned out on BOTH the source /// and destination folder topics — subscribers to either see the /// event once. `from` / `to` are the folder UUIDs; a move /// involving a drive root would be `Option` in a future /// variant, but MVP mutations all address a real folder. FileMoved { file_id: Uuid, name: String, from: Uuid, to: Uuid, actor: Uuid, }, /// A file was deleted (trashed OR permanently removed — the wire /// doesn't distinguish, and clients treat both as "disappears from /// the folder view"). `parent_id` is the folder the file used to /// live in — snapshotted before the delete since the row may be /// gone by publish time. FileDeleted { file_id: Uuid, parent_id: Uuid, actor: Uuid, }, /// A sub-folder was created inside `parent_id`. FolderCreated { folder_id: Uuid, name: String, parent_id: Uuid, actor: Uuid, }, /// A folder was renamed. `parent_id` unchanged. FolderRenamed { folder_id: Uuid, old_name: String, new_name: String, parent_id: Uuid, actor: Uuid, }, /// A folder was moved between parents. Fanned out on BOTH source /// and destination folder topics. FolderMoved { folder_id: Uuid, name: String, from: Uuid, to: Uuid, actor: Uuid, }, /// A folder was deleted (trashed or permanent — see `FileDeleted` /// for the same wire-collapse rationale). FolderDeleted { folder_id: Uuid, parent_id: Uuid, actor: Uuid, }, /// A user's authorization changed — publishes on /// [`Topic::UserAuthz`]. The WS handler auto-subscribes each /// session to its own `user:{caller}:authz` topic; on receipt it /// walks the session's active subscriptions and evicts any whose /// resource is in `affected_folders`, emitting a `rt.revoked` /// notification per evicted topic. /// /// MVP carries folder UUIDs only (the only resource-scoped topic /// that ships in Phase A). When file/drive/calendar topics land, /// the payload extends with additional resource classes — see the /// plan's Phase-B roadmap. AuthzChanged { affected_folders: Vec }, /// A new notification was created for the caller — publishes on /// [`Topic::UserNotifications`]. Payload is deliberately thin: the /// FE learns "there's something new to look at" and calls /// `GET /api/notifications` to load the row. Same recovery path a /// missed push takes on next mount, so the wire event stays a /// pure poke — no fields the bell needs to render on its own. /// /// `kind` is the notification's registered kind slug /// (`share_granted`, `job_completed_for_you`, /// `new_login_from_new_device`, `storage_quota_threshold`, …). /// The FE may use it to route the toast (high-priority kinds pop /// a toast; low-priority ones just bump the badge) but never /// treats it as authoritative — the DB row is the truth. NotificationReceived { notification_id: Uuid, kind: String, created_at: chrono::DateTime, }, /// A background job's run started. Published on /// [`Topic::Job`]. `started_at` is server wall-clock (RFC 3339 /// serialised by serde). Admin dashboard's job-list view uses /// this to flip a row from "idle" to "running" without a /// polling round-trip. JobRunStarted { name: String, started_at: chrono::DateTime, actor: Uuid, }, /// A background job made progress. Published at most every /// 3 seconds per job (throttled at the publish site — see /// scheduler engine). `step` / `total` populate an operator- /// facing progress bar; `message` is a one-line free-form /// status. All three are optional because different jobs have /// different progress semantics (some know the total up front, /// some don't; some can render a step count, some just have a /// running status message). JobRunProgress { name: String, step: Option, total: Option, message: Option, }, /// A background job's run ended. `success = true` for a normal /// completion; `false` for failure / cancelled / paused with /// unhandled outcome. `reason` populates the "click for /// details" flow on the admin dashboard: the notification (Slice /// E) will link to `/admin/jobs/` on the `false` branch, /// where the full outcome and paused-run state live. /// /// Deliberately NOT a rich outcome enum — the admin panel is one /// click away and holds the full detail; the bus event just /// needs to say "done, ok or not". Adding a new outcome nuance /// server-side does NOT churn the wire. JobRunEnded { name: String, success: bool, reason: Option, ended_at: chrono::DateTime, }, } // ════════════════════════════════════════════════════════════════════════════ // JSON-RPC 2.0 error codes — stable, never repurpose // ════════════════════════════════════════════════════════════════════════════ /// JSON-RPC 2.0 `error.code` values used on the WS wire. Follows the spec's /// "server-defined" range `-32000` to `-32099` for our application-defined /// codes; the standard `-326xx` envelope codes are re-exported here too so /// the WS handler has one place to reach for. /// /// See `docs/plan/message-bus.md § JSON-RPC error codes` for the /// wire-`message`/audit-`reason` mapping. pub mod error_code { /// Resource-scoped topic, caller lacks Read (or resource doesn't exist — /// indistinguishable to caller by design). Anti-enum invariant. pub const NO_READ: i32 = -32001; /// Resource-scoped topic requiring `Share`, caller has Read but not /// Share. Applies to `file:{id}:shares` (Phase B). pub const NO_SHARE: i32 = -32002; /// Resource-scoped topic requiring `Comment` (`file:{id}:comments` /// Phase B). pub const NO_COMMENT: i32 = -32003; /// Identity-scoped mismatch OR unknown/malformed topic. Same wire code /// regardless of whether the target user exists — anti-enum. pub const TOPIC_FORBIDDEN: i32 = -32004; /// Per-connection sub cap hit. pub const SUB_LIMIT: i32 = -32005; /// Subscribe-frame token bucket exhausted. pub const RATE_LIMITED: i32 = -32006; /// CRDT edit frame from a caller without `Edit`. Emitted as an /// `rt.write_denied` notification (not tied to a request `id`). pub const NO_EDIT: i32 = -32007; // ────────────────────── JSON-RPC 2.0 standard codes ───────────────────── // Re-exported so the WS handler doesn't reach for two constant lists. /// Server-side failure the client should retry. pub const INTERNAL_ERROR: i32 = -32603; /// Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc` /// version). pub const INVALID_REQUEST: i32 = -32600; /// Method outside the `rt.*` allowlist. pub const METHOD_NOT_FOUND: i32 = -32601; /// Method known but `params` shape wrong (missing `topic`, unparseable). pub const INVALID_PARAMS: i32 = -32602; } // ════════════════════════════════════════════════════════════════════════════ // MessageBus — the port // ════════════════════════════════════════════════════════════════════════════ /// The local-facing message bus. Fire-and-forget publish, stream subscribe. /// /// `publish` is intentionally synchronous — services must not `await` under /// a DB transaction (a slow subscriber could hold the tx open) and services /// should not care whether fan-out is happening in a background task or not. /// /// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc, /// pg listener) without churn at the consumer. pub trait MessageBus: Send + Sync + 'static { /// Fan an event out to every current subscriber of `topic`. Never /// blocks; slow subscribers are dropped by the impl (they'll reconnect /// and refetch). fn publish(&self, topic: &Topic, event: MessageBusEvent); /// Subscribe to `topic`. The returned stream yields events until the /// subscriber is dropped or the impl kicks it out (e.g. for lagging /// too far behind). fn subscribe(&self, topic: &Topic) -> BusStream; } /// Boxed stream returned by [`MessageBus::subscribe`]. Aliased so /// consumers don't need to spell out the `Pin>` shape. pub type BusStream = Pin + Send>>; // ════════════════════════════════════════════════════════════════════════════ // BusReplicator — the multi-instance seam (day-1 noop) // ════════════════════════════════════════════════════════════════════════════ /// Cross-instance replicator. Sits BESIDE [`MessageBus`], not in front of /// it — the bus does the local fan-out; the replicator forwards outbound /// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound /// broker messages back into the local bus. /// /// V1 ships [`NoopReplicator`]. The trait is declared today so wiring the /// day the second impl arrives is drop-in. #[async_trait::async_trait] pub trait BusReplicator: Send + Sync + 'static { /// Called by the local bus for every publish. Fire-and-forget — must not /// block or await; forwarding to the broker happens on a background task /// owned by the impl. fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent); /// Long-running consumer task: reads remote messages and re-publishes /// locally. Returns when `shutdown` is notified — DI calls /// `shutdown.notify_one()` on graceful shutdown. /// /// **Shutdown semantics:** use `Notify::notify_one` (not /// `notify_waiters`) at the signalling site: `notify_one` stores a /// permit if no waiter is currently parked, so signal-before-park is /// safe. `notify_waiters` silently drops signals sent before parking /// and creates a race. This constrains the impl to a single-waiter /// shutdown handle; multi-task replicators must spin their own /// `CancellationToken`-style fan-out internally. async fn run(self: Arc, shutdown: Arc) -> Result<(), DomainError>; } /// Day-1 replicator: does nothing. Wired unconditionally so callers hold /// `Arc` uniformly. Swapped for a real impl when /// multi-instance deployment matters. #[derive(Default)] pub struct NoopReplicator; #[async_trait::async_trait] impl BusReplicator for NoopReplicator { fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) { // Intentionally empty. Local fan-out already happened in the bus. } async fn run(self: Arc, shutdown: Arc) -> Result<(), DomainError> { // Park until shutdown so the DI-managed handle stays alive with the // same lifecycle as a future real replicator. shutdown.notified().await; Ok(()) } } // ════════════════════════════════════════════════════════════════════════════ // Tests // ════════════════════════════════════════════════════════════════════════════ #[cfg(test)] mod tests { use super::*; #[test] fn folder_topic_roundtrip() { let id = Uuid::new_v4(); let t = Topic::Folder(id); let wire = t.to_wire_key(); assert_eq!(wire, format!("folder:{id}")); assert_eq!(Topic::parse(&wire).unwrap(), t); } #[test] fn user_authz_topic_roundtrip() { let id = Uuid::new_v4(); let t = Topic::UserAuthz(id); let wire = t.to_wire_key(); assert_eq!(wire, format!("user:{id}:authz")); assert_eq!(Topic::parse(&wire).unwrap(), t); } #[test] fn user_notifications_topic_roundtrip() { let id = Uuid::new_v4(); let t = Topic::UserNotifications(id); let wire = t.to_wire_key(); assert_eq!(wire, format!("user:{id}:notifications")); assert_eq!(Topic::parse(&wire).unwrap(), t); } #[test] fn job_topic_roundtrip() { let t = Topic::Job("backend_migration".to_string()); let wire = t.to_wire_key(); assert_eq!(wire, "job:backend_migration"); assert_eq!(Topic::parse(&wire).unwrap(), t); } #[test] fn job_topic_rejects_bad_name_chars() { // Job names come from the scheduler registry — a stable // `[a-z0-9_-]` alphabet. Anything else is `Unknown` (same // wire response as an unrecognised topic shape). assert_eq!(Topic::parse("job:"), Err(ParseTopicErr::Unknown)); assert_eq!(Topic::parse("job:UPPER"), Err(ParseTopicErr::Unknown)); assert_eq!(Topic::parse("job:with.dot"), Err(ParseTopicErr::Unknown)); assert_eq!(Topic::parse("job:with space"), Err(ParseTopicErr::Unknown)); } #[test] fn required_perm_job_is_role_admin() { assert_eq!( Topic::Job("thumb_derived_import".to_string()).required_perm(), AuthzCheck::RoleAdmin ); } #[test] fn parse_rejects_bad_uuid() { assert_eq!( Topic::parse("folder:not-a-uuid"), Err(ParseTopicErr::BadUuid) ); } #[test] fn parse_rejects_unknown_shape() { assert_eq!(Topic::parse(""), Err(ParseTopicErr::Unknown)); assert_eq!(Topic::parse("unknown:x"), Err(ParseTopicErr::Unknown)); assert_eq!( Topic::parse(&format!("user:{}", Uuid::new_v4())), Err(ParseTopicErr::Unknown), "user: without a known suffix (:authz, :notifications) is not a known topic" ); assert_eq!( Topic::parse(&format!("user:{}:whatever", Uuid::new_v4())), Err(ParseTopicErr::Unknown), "an unrecognised suffix rejects — no partial match on the prefix" ); } #[test] fn required_perm_folder_is_resource_read() { let id = Uuid::new_v4(); assert_eq!( Topic::Folder(id).required_perm(), AuthzCheck::ResourceRead { resource: BusResource::Folder(id) } ); } #[test] fn required_perm_user_authz_is_identity_match() { let id = Uuid::new_v4(); assert_eq!( Topic::UserAuthz(id).required_perm(), AuthzCheck::IdentityMatch { user_id: id } ); } #[test] fn required_perm_user_notifications_is_identity_match() { // Same strict-privacy gate as :authz — no admin bypass, direct // UUID equality, anti-enum parity. A regression here would // let admins snoop on other users' notification streams. let id = Uuid::new_v4(); assert_eq!( Topic::UserNotifications(id).required_perm(), AuthzCheck::IdentityMatch { user_id: id } ); } #[test] fn event_serializes_with_snake_case_discriminator() { // The `#[serde(tag = "event")]` shape is the WS wire contract for // the `rt.event` JSON-RPC notification's `params.event` field. Pin // every variant's discriminator with a snapshot so an accidental // rename fails the test instead of silently breaking clients — // the AsyncAPI spec's `event` enum mirrors these exact strings. let cases: &[(MessageBusEvent, &str)] = &[ ( MessageBusEvent::FileCreated { file_id: Uuid::nil(), name: "notes.md".into(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "file_created", ), ( MessageBusEvent::FileRenamed { file_id: Uuid::nil(), old_name: "a.md".into(), new_name: "b.md".into(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "file_renamed", ), ( MessageBusEvent::FileMoved { file_id: Uuid::nil(), name: "a.md".into(), from: Uuid::nil(), to: Uuid::nil(), actor: Uuid::nil(), }, "file_moved", ), ( MessageBusEvent::FileDeleted { file_id: Uuid::nil(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "file_deleted", ), ( MessageBusEvent::FolderCreated { folder_id: Uuid::nil(), name: "docs".into(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "folder_created", ), ( MessageBusEvent::FolderRenamed { folder_id: Uuid::nil(), old_name: "old".into(), new_name: "new".into(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "folder_renamed", ), ( MessageBusEvent::FolderMoved { folder_id: Uuid::nil(), name: "docs".into(), from: Uuid::nil(), to: Uuid::nil(), actor: Uuid::nil(), }, "folder_moved", ), ( MessageBusEvent::FolderDeleted { folder_id: Uuid::nil(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, "folder_deleted", ), ( MessageBusEvent::AuthzChanged { affected_folders: vec![Uuid::nil()], }, "authz_changed", ), ( MessageBusEvent::NotificationReceived { notification_id: Uuid::nil(), kind: "share_granted".into(), created_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), }, "notification_received", ), ( MessageBusEvent::JobRunStarted { name: "backend_migration".into(), started_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), actor: Uuid::nil(), }, "job_run_started", ), ( MessageBusEvent::JobRunProgress { name: "backend_migration".into(), step: Some(10), total: Some(100), message: Some("phase 2".into()), }, "job_run_progress", ), ( MessageBusEvent::JobRunEnded { name: "backend_migration".into(), success: true, reason: None, ended_at: chrono::DateTime::::from_timestamp(0, 0).unwrap(), }, "job_run_ended", ), ]; for (ev, expected) in cases { let json = serde_json::to_value(ev).unwrap(); assert_eq!( json["event"], *expected, "wire discriminator mismatch for {ev:?}" ); } } #[test] fn event_roundtrip() { let file_id = Uuid::new_v4(); let parent_id = Uuid::new_v4(); let actor = Uuid::new_v4(); let original = MessageBusEvent::FileCreated { file_id, name: "a.txt".into(), parent_id, actor, }; let json = serde_json::to_string(&original).unwrap(); let decoded: MessageBusEvent = serde_json::from_str(&json).unwrap(); assert_eq!(decoded, original); } #[test] fn error_codes_stay_in_the_defined_ranges() { // Application-defined codes live in the JSON-RPC "server-defined" // range -32000..=-32099. Standard envelope codes live in // -32700..=-32600. A refactor that moves a value out of its range // is a wire-break — pin it here. for code in [ error_code::NO_READ, error_code::NO_SHARE, error_code::NO_COMMENT, error_code::TOPIC_FORBIDDEN, error_code::SUB_LIMIT, error_code::RATE_LIMITED, error_code::NO_EDIT, ] { assert!( (-32099..=-32000).contains(&code), "app-defined code {code} outside -32099..=-32000" ); } for code in [ error_code::INTERNAL_ERROR, error_code::INVALID_REQUEST, error_code::METHOD_NOT_FOUND, error_code::INVALID_PARAMS, ] { assert!( (-32700..=-32600).contains(&code), "standard code {code} outside -32700..=-32600" ); } } #[tokio::test] async fn noop_replicator_parks_until_notified() { let repl = Arc::new(NoopReplicator); let shutdown = Arc::new(Notify::new()); let handle = tokio::spawn({ let repl = Arc::clone(&repl); let shutdown = Arc::clone(&shutdown); async move { BusReplicator::run(repl, shutdown).await } }); // on_local_publish is a no-op that should not panic or spawn work. repl.on_local_publish( &Topic::Folder(Uuid::nil()), &MessageBusEvent::FileCreated { file_id: Uuid::nil(), name: "x".into(), parent_id: Uuid::nil(), actor: Uuid::nil(), }, ); // `notify_one` (not `notify_waiters`) so the signal survives if the // spawned task hasn't yet reached `.notified().await` — permit // queues instead of being dropped. See BusReplicator docs. shutdown.notify_one(); handle.await.unwrap().unwrap(); } }