Skip to main content

forge_workspace/mcp/gotify/
facade.rs

1//! `GotifyFacade` - the seam between the Gotify MCP tools and workspace
2//! state. The production impl ([`ProdGotifyFacade`]) resolves the
3//! caller's project + durable identity and drives the direct
4//! `Workspace` subscription methods; the mock records calls for tool
5//! tests.
6
7use std::sync::{Arc, Weak};
8use std::time::SystemTime;
9
10use forge_connectors::gotify::{GotifyRecent, refresh_app_index};
11use forge_primitives::GotifySubscription;
12use uuid::Uuid;
13
14use crate::SessionSlot;
15use crate::gotify::SubsystemHost;
16use crate::mcp::caller_context::caller_context;
17use crate::workspace::Workspace;
18
19/// Why `gotify__subscribe` failed.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) enum GotifySubscribeError {
22    /// No `[gotify]` block in forge.toml - the server is unconfigured.
23    NotConfigured,
24    /// The caller couldn't be mapped to a project (transient race, or
25    /// the session isn't attached to a forge.toml project).
26    UnknownCallerProject,
27}
28
29/// A created subscription's effective filter, plus whether that filter can
30/// resolve a message. `names_resolve` is false only when the filter names
31/// applications and the `/application` lookup behind them failed: the
32/// subscription is live, but those names will not match until the next
33/// stream reconnect. Always true for a match-any filter, which needs no
34/// names.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(crate) struct SubscribeOutcome {
37    pub id: Uuid,
38    /// The app names the subscription matches; empty matches any app.
39    pub applications: Vec<String>,
40    /// The priority floor, `None` for any.
41    pub min_priority: Option<u8>,
42    pub names_resolve: bool,
43}
44
45/// Why a read-only Gotify tool (`gotify__apps` / `gotify__recent`) failed.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) enum GotifyReadError {
48    /// No `[gotify]` block in forge.toml - the server is unconfigured.
49    NotConfigured,
50    /// The REST request to the server failed (network, HTTP status, or
51    /// body parse). Carries the formatted error chain for the LLM.
52    Fetch(String),
53}
54
55/// Why `gotify__unsubscribe` failed. The last two are the two cases a
56/// refusal keeps apart: an id the project holds under another session
57/// reads differently from one the project never had.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) enum GotifyUnsubscribeError {
60    /// The caller couldn't be mapped to a project (transient race, or the
61    /// session isn't attached to a forge.toml project).
62    UnknownCallerProject,
63    /// No subscription with that id in the caller's project.
64    NoSuchSubscription,
65    /// The id is in the caller's project but belongs to another owner.
66    NotOwnedByCaller,
67}
68
69/// The Gotify tools' view of the workspace. The subscription mutations
70/// are synchronous state writes; the read-only `apps` / `recent` lookups
71/// hit the server's REST API and are async.
72#[async_trait::async_trait]
73pub(crate) trait GotifyFacade: Send + Sync {
74    /// Subscribe the caller's durable identity to the configured server,
75    /// optionally filtered by application names and/or minimum priority.
76    /// An empty `applications` matches any app. Persists when the identity
77    /// is durable. Async because a filter that names applications refreshes
78    /// the app index before returning.
79    async fn subscribe(
80        &self,
81        caller: &SessionSlot,
82        applications: Vec<String>,
83        min_priority: Option<u8>,
84    ) -> Result<SubscribeOutcome, GotifySubscribeError>;
85
86    /// The caller's own subscriptions in its project - a lead's, or one
87    /// worker's, never another owner's.
88    fn list(&self, caller: &SessionSlot) -> Vec<GotifySubscription>;
89
90    /// Remove one of the caller's OWN subscriptions by id within its
91    /// project, returning the row as it stood. A refusal says which case
92    /// it is: [`GotifyUnsubscribeError::NoSuchSubscription`] when the
93    /// project has no such id, [`GotifyUnsubscribeError::NotOwnedByCaller`]
94    /// when it has one under another owner.
95    fn unsubscribe(
96        &self,
97        caller: &SessionSlot,
98        id: Uuid,
99    ) -> Result<GotifySubscription, GotifyUnsubscribeError>;
100
101    /// The application NAMEs on the configured server (`GET /application`)
102    /// so a session can self-discover what it may subscribe to.
103    async fn apps(&self) -> Result<Vec<String>, GotifyReadError>;
104
105    /// The most recent notifications, newest first, filtered by
106    /// application NAME (empty = any) and `min_priority` (`None` = any),
107    /// capped at `limit`. A catch-up read for a woken or live session.
108    async fn recent(
109        &self,
110        applications: Vec<String>,
111        min_priority: Option<u8>,
112        limit: usize,
113    ) -> Result<Vec<GotifyRecent>, GotifyReadError>;
114}
115
116/// Production facade over `Weak<Workspace>` (weak to avoid a cycle with
117/// the MCP server the workspace owns).
118pub(crate) struct ProdGotifyFacade {
119    workspace: Weak<Workspace>,
120}
121
122impl ProdGotifyFacade {
123    pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn GotifyFacade> {
124        Arc::new(Self { workspace: Arc::downgrade(workspace) })
125    }
126}
127
128#[async_trait::async_trait]
129impl GotifyFacade for ProdGotifyFacade {
130    async fn subscribe(
131        &self,
132        caller: &SessionSlot,
133        applications: Vec<String>,
134        min_priority: Option<u8>,
135    ) -> Result<SubscribeOutcome, GotifySubscribeError> {
136        let ws = self.workspace.upgrade().ok_or(GotifySubscribeError::UnknownCallerProject)?;
137        let cfg = ws.gotify_config().ok_or(GotifySubscribeError::NotConfigured)?;
138        let (project, team_role, durable) =
139            resolve_identity(&ws, caller).ok_or(GotifySubscribeError::UnknownCallerProject)?;
140        let sub = GotifySubscription {
141            id: Uuid::new_v4(),
142            project,
143            team_role,
144            applications,
145            min_priority,
146            created_at: SystemTime::now(),
147        };
148        let names_applications = !sub.applications.is_empty();
149        let id = sub.id;
150        let applications = sub.applications.clone();
151        ws.add_gotify_subscription(sub, durable);
152        // The pump starts before the lookup: it must not wait behind a
153        // network round trip, and no window may sit between the
154        // subscription and the pump that runs it.
155        ws.start_gotify_subsystem();
156        // An application the server created after the stream connected is
157        // absent from the cached index, so a filter naming it matches
158        // nothing until the next reconnect.
159        let names_resolve = if names_applications {
160            refresh_app_index(&SubsystemHost::new(&ws), &cfg).await
161        } else {
162            true
163        };
164        Ok(SubscribeOutcome { id, applications, min_priority, names_resolve })
165    }
166
167    fn list(&self, caller: &SessionSlot) -> Vec<GotifySubscription> {
168        let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
169        let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
170        // Symmetric with `cron__list`: every caller sees only its own
171        // subscriptions - a lead (`worker_label == None`) the lead ones,
172        // a worker its own.
173        ws.gotify_subscriptions_for_project(&cx.project_name)
174            .into_iter()
175            .filter(|s| s.team_role == cx.worker_label)
176            .collect()
177    }
178
179    fn unsubscribe(
180        &self,
181        caller: &SessionSlot,
182        id: Uuid,
183    ) -> Result<GotifySubscription, GotifyUnsubscribeError> {
184        let ws = self.workspace.upgrade().ok_or(GotifyUnsubscribeError::UnknownCallerProject)?;
185        let cx = caller_context(&ws, caller).ok_or(GotifyUnsubscribeError::UnknownCallerProject)?;
186        let removed = ws.remove_gotify_subscription_owned_by(
187            &cx.project_name,
188            id,
189            cx.worker_label.as_deref(),
190        );
191        ws.stop_gotify_subsystem_if_idle();
192        if let Some(removed) = removed {
193            return Ok(removed);
194        }
195        // The owner-scoped removal matched nothing, which is either an id
196        // the project never had or one another session owns; the caller is
197        // told which.
198        if ws.gotify_subscriptions_for_project(&cx.project_name).iter().any(|s| s.id == id) {
199            Err(GotifyUnsubscribeError::NotOwnedByCaller)
200        } else {
201            Err(GotifyUnsubscribeError::NoSuchSubscription)
202        }
203    }
204
205    async fn apps(&self) -> Result<Vec<String>, GotifyReadError> {
206        let ws = self.workspace.upgrade().ok_or(GotifyReadError::NotConfigured)?;
207        let cfg = ws.gotify_config().ok_or(GotifyReadError::NotConfigured)?;
208        forge_connectors::gotify::app_names(&SubsystemHost::new(&ws), &cfg)
209            .await
210            .map_err(|err| GotifyReadError::Fetch(format!("{err:#}")))
211    }
212
213    async fn recent(
214        &self,
215        applications: Vec<String>,
216        min_priority: Option<u8>,
217        limit: usize,
218    ) -> Result<Vec<GotifyRecent>, GotifyReadError> {
219        let ws = self.workspace.upgrade().ok_or(GotifyReadError::NotConfigured)?;
220        let cfg = ws.gotify_config().ok_or(GotifyReadError::NotConfigured)?;
221        forge_connectors::gotify::recent_messages(
222            &SubsystemHost::new(&ws),
223            &cfg,
224            &applications,
225            min_priority,
226            limit,
227        )
228        .await
229        .map_err(|err| GotifyReadError::Fetch(format!("{err:#}")))
230    }
231}
232
233/// Resolve a caller to `(project_name, team_role, durable)`. `team_role`
234/// is the worker's role label (`None` targets the lead); `durable` is
235/// true for the lead or a worker with a persisted row in the session
236/// store, false for a worker without one.
237pub(crate) fn resolve_identity(
238    ws: &Workspace,
239    caller: &SessionSlot,
240) -> Option<(String, Option<String>, bool)> {
241    let cx = caller_context(ws, caller)?;
242    let worker_label = if cx.is_lead {
243        None
244    } else {
245        ws.list_live_workers(&cx.project_key)
246            .into_iter()
247            .find(|w| w.slot == *caller)
248            .map(|w| w.label)
249    };
250    let dynamic_labels: Vec<String> =
251        ws.worker_rows_for_project(&cx.project_key).into_iter().map(|w| w.label).collect();
252    let (team_role, durable) = durable_identity(worker_label.as_deref(), &dynamic_labels);
253    Some((cx.project_name, team_role, durable))
254}
255
256/// `(team_role, durable)` for a caller's worker label (`None` = the lead
257/// or a plain catalog session). A lead is always durable and targets
258/// itself. A worker is durable when its label holds a persisted row in
259/// the session store, since that row is what brings it back.
260fn durable_identity(
261    worker_label: Option<&str>,
262    dynamic_labels: &[String],
263) -> (Option<String>, bool) {
264    match worker_label {
265        None => (None, true),
266        Some(label) => {
267            let durable = dynamic_labels.iter().any(|t| t == label);
268            (Some(label.to_owned()), durable)
269        }
270    }
271}
272
273/// Records calls + returns preloaded results so the tool tests can assert
274/// the tool correctly parses args, resolves the caller, and surfaces
275/// facade results/errors - without a real workspace.
276/// One recorded `subscribe` call: `(caller, applications, min_priority)`.
277#[cfg(test)]
278type SubscribeCall = (SessionSlot, Vec<String>, Option<u8>);
279
280/// One recorded `recent` call: `(applications, min_priority, limit)`.
281#[cfg(test)]
282type RecentCall = (Vec<String>, Option<u8>, usize);
283
284#[cfg(test)]
285#[derive(Default)]
286pub(crate) struct MockGotifyFacade {
287    pub subs: parking_lot::Mutex<Vec<GotifySubscription>>,
288    pub subscribe_calls: parking_lot::Mutex<Vec<SubscribeCall>>,
289    pub subscribe_result:
290        parking_lot::Mutex<Option<Result<SubscribeOutcome, GotifySubscribeError>>>,
291    pub unsubscribe_calls: parking_lot::Mutex<Vec<(SessionSlot, Uuid)>>,
292    pub unsubscribe_result:
293        parking_lot::Mutex<Option<Result<GotifySubscription, GotifyUnsubscribeError>>>,
294    pub apps_result: parking_lot::Mutex<Option<Result<Vec<String>, GotifyReadError>>>,
295    pub recent_calls: parking_lot::Mutex<Vec<RecentCall>>,
296    pub recent_result: parking_lot::Mutex<Option<Result<Vec<GotifyRecent>, GotifyReadError>>>,
297}
298
299#[cfg(test)]
300impl MockGotifyFacade {
301    pub(crate) fn new() -> Self {
302        Self::default()
303    }
304    pub(crate) fn into_arc(self) -> Arc<dyn GotifyFacade> {
305        Arc::new(self)
306    }
307}
308
309#[cfg(test)]
310#[async_trait::async_trait]
311impl GotifyFacade for MockGotifyFacade {
312    async fn subscribe(
313        &self,
314        caller: &SessionSlot,
315        applications: Vec<String>,
316        min_priority: Option<u8>,
317    ) -> Result<SubscribeOutcome, GotifySubscribeError> {
318        self.subscribe_calls.lock().push((caller.clone(), applications.clone(), min_priority));
319        self.subscribe_result.lock().clone().unwrap_or_else(|| {
320            Ok(SubscribeOutcome {
321                id: Uuid::nil(),
322                applications,
323                min_priority,
324                names_resolve: true,
325            })
326        })
327    }
328
329    fn list(&self, _caller: &SessionSlot) -> Vec<GotifySubscription> {
330        self.subs.lock().clone()
331    }
332
333    fn unsubscribe(
334        &self,
335        caller: &SessionSlot,
336        id: Uuid,
337    ) -> Result<GotifySubscription, GotifyUnsubscribeError> {
338        self.unsubscribe_calls.lock().push((caller.clone(), id));
339        self.unsubscribe_result
340            .lock()
341            .clone()
342            .unwrap_or(Err(GotifyUnsubscribeError::NoSuchSubscription))
343    }
344
345    async fn apps(&self) -> Result<Vec<String>, GotifyReadError> {
346        self.apps_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
347    }
348
349    async fn recent(
350        &self,
351        applications: Vec<String>,
352        min_priority: Option<u8>,
353        limit: usize,
354    ) -> Result<Vec<GotifyRecent>, GotifyReadError> {
355        self.recent_calls.lock().push((applications, min_priority, limit));
356        self.recent_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::WorkerEntry;
364    use crate::mcp::gotify::Unsubscribe;
365    use crate::mcp::test_support::text_of;
366    use crate::target::ProjectKey;
367    use forge_connectors::gotify::GotifyHost as _;
368    use forge_primitives::WorkerLiveness;
369    use forge_sdk::mcp::tool::{Tool as _, ToolInput};
370    use std::collections::HashMap;
371    use std::sync::atomic::{AtomicUsize, Ordering};
372
373    /// A throwaway HTTP server answering the Gotify `/application` lookup,
374    /// counting how often it was asked. Every other path 404s, so the
375    /// pump's stream upgrade fails fast instead of hanging the test.
376    struct AppStub {
377        addr: std::net::SocketAddr,
378        application_fetches: Arc<AtomicUsize>,
379    }
380
381    impl AppStub {
382        fn start(status: &'static str, body: &'static str) -> Self {
383            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind app stub");
384            let addr = listener.local_addr().expect("app stub addr");
385            let application_fetches = Arc::new(AtomicUsize::new(0));
386            let counter = Arc::clone(&application_fetches);
387            std::thread::spawn(move || {
388                for socket in listener.incoming() {
389                    let Ok(mut socket) = socket else { continue };
390                    let mut raw = Vec::new();
391                    let mut chunk = [0u8; 1024];
392                    while !raw.windows(4).any(|w| w == b"\r\n\r\n") {
393                        match std::io::Read::read(&mut socket, &mut chunk) {
394                            Ok(0) | Err(_) => break,
395                            Ok(read) => raw.extend_from_slice(&chunk[..read]),
396                        }
397                    }
398                    let head = String::from_utf8_lossy(&raw);
399                    let (status, body) = if head.starts_with("GET /application ") {
400                        counter.fetch_add(1, Ordering::SeqCst);
401                        (status, body)
402                    } else {
403                        ("404 Not Found", "{}")
404                    };
405                    let response = format!(
406                        "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
407                        body.len()
408                    );
409                    let _ = std::io::Write::write_all(&mut socket, response.as_bytes());
410                }
411            });
412            Self { addr, application_fetches }
413        }
414
415        fn url(&self) -> String {
416            format!("http://{}", self.addr)
417        }
418
419        fn application_fetches(&self) -> usize {
420            self.application_fetches.load(Ordering::SeqCst)
421        }
422    }
423
424    /// A workspace carrying a real `[gotify]` config pointed at `stub`,
425    /// plus the caller's project, so `ProdGotifyFacade::subscribe` runs
426    /// its whole path against the real connector.
427    fn subscribe_fixture(
428        apps_status: &'static str,
429        apps_body: &'static str,
430    ) -> (tempfile::TempDir, Arc<Workspace>, Arc<dyn GotifyFacade>, SessionSlot, AppStub) {
431        let dir = tempfile::tempdir().expect("tempdir");
432        let stub = AppStub::start(apps_status, apps_body);
433        let forge = crate::config::ensure_forge_data_dir(dir.path()).expect("forge/ dir");
434        std::fs::write(
435            forge.join("forge.toml"),
436            format!(
437                r#"
438[[orgs]]
439name = "TestOrg"
440accounts = ["acct-a"]
441
442[[orgs.projects]]
443name = "myproj"
444path = "/tmp/gotify-subscribe-refresh"
445
446[[accounts]]
447display_name = "acct-a"
448token = "t"
449models = ["claude-sonnet-5"]
450provider = "anthropic"
451
452[gotify]
453url = "{}"
454client_token = "Ctest"
455"#,
456                stub.url(),
457            ),
458        )
459        .expect("write forge.toml");
460        let config = crate::config::load_from_dir(dir.path()).expect("load the fixture config");
461        let (ws, _rx) =
462            Workspace::testing_stub_with_config(dir.path().to_owned(), config).expect("stub");
463        let facade = ProdGotifyFacade::from_arc(&ws);
464        (dir, ws, facade, SessionSlot::lead("TestOrg", "myproj"), stub)
465    }
466
467    /// The reported flow: an application created after the stream
468    /// connected is absent from the cached index, so a subscription
469    /// naming it silently matches nothing until the next reconnect.
470    /// Subscribe must land the index that resolves it.
471    #[tokio::test]
472    async fn subscribe_refreshes_the_app_index_so_a_new_application_matches() {
473        let (_dir, ws, facade, caller, stub) = subscribe_fixture(
474            "200 OK",
475            r#"[{"id":7,"name":"phone-agent","token":"A.x","description":"","image":""}]"#,
476        );
477        // The cached index is what a connect-time fetch left behind: it
478        // predates the application the caller is about to name.
479        let host = SubsystemHost::new(&ws);
480        host.store_app_index(HashMap::new());
481
482        let outcome = facade
483            .subscribe(&caller, vec!["phone-agent".to_owned()], None)
484            .await
485            .expect("subscribe to the configured server");
486
487        assert!(outcome.names_resolve, "a refreshed index leaves the name filter resolvable");
488        assert_eq!(
489            outcome.applications,
490            ["phone-agent"],
491            "the outcome carries the filter that was stored, not an empty default",
492        );
493        assert_eq!(outcome.min_priority, None, "and the priority floor it registered");
494        assert_eq!(stub.application_fetches(), 1, "the subscribe refreshed the index");
495        let resolved = host.app_name(7);
496        let subs = ws.gotify_subscriptions_for_project("myproj");
497        let matched =
498            forge_connectors::gotify::matching_subscriptions(&subs, resolved.as_deref(), 5);
499        assert_eq!(
500            matched.len(),
501            1,
502            "a message from the newly created application matches its subscription: resolved={resolved:?}",
503        );
504    }
505
506    /// A filter that names no application matches anything, so it needs no
507    /// names to resolve and must not pay for a lookup.
508    #[tokio::test]
509    async fn subscribe_without_application_names_does_not_fetch_the_index() {
510        let (_dir, _ws, facade, caller, stub) = subscribe_fixture("200 OK", "[]");
511
512        let outcome = facade
513            .subscribe(&caller, Vec::new(), Some(5))
514            .await
515            .expect("subscribe without filters");
516
517        assert!(outcome.names_resolve, "a match-any filter needs no names, so it always resolves");
518        assert!(outcome.applications.is_empty(), "a match-any filter names no applications");
519        assert_eq!(
520            outcome.min_priority,
521            Some(5),
522            "the outcome carries the priority floor that was stored",
523        );
524        assert_eq!(
525            stub.application_fetches(),
526            0,
527            "a match-any subscribe names no applications, so it must not fetch /application",
528        );
529    }
530
531    /// The failing refresh is the one case the reply can speak to: without
532    /// it, a subscribe that reports success and matches nothing is exactly
533    /// what #1298 was filed for.
534    #[tokio::test]
535    async fn subscribe_reports_a_failed_index_refresh_and_keeps_the_subscription() {
536        let (_dir, ws, facade, caller, _stub) =
537            subscribe_fixture("500 Internal Server Error", "{}");
538
539        let outcome = facade
540            .subscribe(&caller, vec!["phone-agent".to_owned()], None)
541            .await
542            .expect("the subscription itself still succeeds");
543
544        assert!(
545            !outcome.names_resolve,
546            "an unrefreshable index leaves the name filter unresolvable, and that is reported",
547        );
548        assert_eq!(
549            ws.gotify_subscriptions_for_project("myproj").len(),
550            1,
551            "the subscription is created whether or not the index refreshed",
552        );
553    }
554
555    fn worker_entry(project: &str, label: &str) -> WorkerEntry {
556        WorkerEntry {
557            label: label.to_owned(),
558            charter: "watch".to_owned(),
559            slot: SessionSlot::worker("TestOrg", project, label),
560            session_id: None,
561            status: WorkerLiveness::Running,
562            spawned_at: SystemTime::UNIX_EPOCH,
563            spawned_by: SessionSlot::lead("TestOrg", project),
564            needs_tag: false,
565            is_git_repo_at_spawn: false,
566            cwd: None,
567            diagnostic: None,
568            kick: None,
569        }
570    }
571
572    /// A project with a live lead plus two live workers, mirroring the
573    /// cron facade's fixture so the two families are tested the same way.
574    fn fixture() -> (Arc<Workspace>, Arc<dyn GotifyFacade>, ProjectKey, SessionSlot, SessionSlot) {
575        let (ws, _rx) = Workspace::testing_stub();
576        ws.seed_test_project("myproj", "/tmp/gotify-scope");
577        let key =
578            ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("seeded view").key;
579        ws.record_connected_session("/tmp/gotify-scope", "lead-uuid", None);
580        ws.insert_live_worker(&key, worker_entry("myproj", "reviewer"));
581        ws.insert_live_worker(&key, worker_entry("myproj", "analyst"));
582        let facade = ProdGotifyFacade::from_arc(&ws);
583        (
584            ws,
585            facade,
586            key,
587            SessionSlot::lead("TestOrg", "myproj"),
588            SessionSlot::worker("TestOrg", "myproj", "reviewer"),
589        )
590    }
591
592    fn seed_sub(ws: &Workspace, team_role: Option<&str>) -> Uuid {
593        let sub = GotifySubscription {
594            id: Uuid::new_v4(),
595            project: "myproj".to_owned(),
596            team_role: team_role.map(str::to_owned),
597            applications: vec!["alerts".to_owned()],
598            min_priority: None,
599            created_at: SystemTime::UNIX_EPOCH,
600        };
601        let id = sub.id;
602        ws.add_gotify_subscription(sub, true);
603        id
604    }
605
606    #[test]
607    fn list_is_scoped_to_the_callers_own_subscriptions() {
608        let (ws, facade, _key, lead, worker) = fixture();
609        let lead_id = seed_sub(&ws, None);
610        let worker_id = seed_sub(&ws, Some("reviewer"));
611        seed_sub(&ws, Some("analyst"));
612
613        let worker_list = facade.list(&worker);
614        assert_eq!(
615            worker_list.iter().map(|s| s.id).collect::<Vec<_>>(),
616            vec![worker_id],
617            "a worker sees neither the lead's subscription nor a sibling's",
618        );
619        let lead_list = facade.list(&lead);
620        assert_eq!(
621            lead_list.iter().map(|s| s.id).collect::<Vec<_>>(),
622            vec![lead_id],
623            "a lead sees only its own subscriptions",
624        );
625    }
626
627    #[test]
628    fn unsubscribe_only_removes_the_callers_own_subscription() {
629        let (ws, facade, _key, lead, worker) = fixture();
630        let lead_id = seed_sub(&ws, None);
631        let worker_id = seed_sub(&ws, Some("reviewer"));
632        let sibling_id = seed_sub(&ws, Some("analyst"));
633
634        assert_eq!(
635            facade.unsubscribe(&worker, lead_id),
636            Err(GotifyUnsubscribeError::NotOwnedByCaller),
637            "a worker cannot unsubscribe the lead's, and the refusal says which case it is",
638        );
639        assert_eq!(
640            facade.unsubscribe(&worker, sibling_id),
641            Err(GotifyUnsubscribeError::NotOwnedByCaller),
642            "a worker cannot unsubscribe a sibling worker's",
643        );
644        assert_eq!(
645            facade.unsubscribe(&lead, worker_id),
646            Err(GotifyUnsubscribeError::NotOwnedByCaller),
647            "a lead cannot unsubscribe a worker's",
648        );
649        assert_eq!(
650            ws.gotify_subscriptions_for_project("myproj").len(),
651            3,
652            "a refused unsubscribe removes nothing",
653        );
654
655        let removed = facade
656            .unsubscribe(&worker, worker_id)
657            .expect("a worker unsubscribes its own, and gets the row back");
658        assert_eq!(removed.id, worker_id, "the echoed row is the one that went");
659        assert_eq!(removed.applications, ["alerts"], "carrying its effective filter");
660        facade.unsubscribe(&lead, lead_id).expect("a lead unsubscribes its own");
661        assert_eq!(
662            ws.gotify_subscriptions_for_project("myproj").iter().map(|s| s.id).collect::<Vec<_>>(),
663            vec![sibling_id],
664            "only the untouched sibling subscription remains",
665        );
666    }
667
668    /// The other half of the refusal: an id the project never had is a
669    /// different answer from one that exists under another session.
670    #[test]
671    fn unsubscribe_refuses_an_id_the_project_never_had() {
672        let (_ws, facade, _key, lead, _worker) = fixture();
673
674        assert_eq!(
675            facade.unsubscribe(&lead, Uuid::from_u128(0xdead)),
676            Err(GotifyUnsubscribeError::NoSuchSubscription),
677            "an id with no entry in the project is reported as absent",
678        );
679    }
680
681    /// Both clauses of that message are about the caller's own project:
682    /// an id living in another project is not "in this project but not
683    /// subscribed by you", it is not in this project at all.
684    #[test]
685    fn unsubscribe_refuses_an_id_from_another_project_as_absent() {
686        let (ws, facade, _key, lead, _worker) = fixture();
687        ws.seed_test_project("otherproj", "/tmp/gotify-scope-other");
688        let other = GotifySubscription {
689            id: Uuid::new_v4(),
690            project: "otherproj".to_owned(),
691            team_role: None,
692            applications: vec!["alerts".to_owned()],
693            min_priority: None,
694            created_at: SystemTime::UNIX_EPOCH,
695        };
696        let other_id = other.id;
697        ws.add_gotify_subscription(other, true);
698
699        assert_eq!(
700            facade.unsubscribe(&lead, other_id),
701            Err(GotifyUnsubscribeError::NoSuchSubscription),
702            "an id that lives in another project is absent here, not a subscription of this project's",
703        );
704    }
705
706    /// The refusal is what a caller acts on, so it has to name the case:
707    /// a row the project holds under another session reads as *not
708    /// yours*, an id the project never had as *not there*. One string
709    /// for both lets a caller take its own refused unsubscribe as proof
710    /// the subscription is gone.
711    #[tokio::test]
712    async fn unsubscribe_refusals_name_the_case_through_the_tool() {
713        let (ws, facade, _key, _lead, worker) = fixture();
714        let lead_id = seed_sub(&ws, None);
715        let tool = Unsubscribe { facade, slot: worker };
716
717        let not_yours =
718            tool.call(ToolInput { value: serde_json::json!({ "id": lead_id.to_string() }) }).await;
719        assert!(not_yours.is_error, "a refused unsubscribe is an error");
720        assert_eq!(
721            text_of(&not_yours),
722            format!("subscription {lead_id} exists in this project but was not subscribed by you"),
723            "a live subscription owned by another session must say so, not report it missing",
724        );
725
726        let ghost = Uuid::from_u128(0xdead);
727        let not_there =
728            tool.call(ToolInput { value: serde_json::json!({ "id": ghost.to_string() }) }).await;
729        assert!(not_there.is_error, "an unknown id is an error");
730        assert_eq!(
731            text_of(&not_there),
732            format!("no subscription with id {ghost} in this project"),
733            "an id the project never had must not read as another session's subscription",
734        );
735    }
736
737    /// Ownership is enforced at the MCP facade, NOT in the workspace
738    /// teardown method, so a despawn still clears a departing worker's
739    /// subscriptions with no MCP caller involved.
740    #[test]
741    fn worker_teardown_clears_subscriptions_without_going_through_the_facade() {
742        let (ws, _facade, key, _lead, _worker) = fixture();
743        let lead_id = seed_sub(&ws, None);
744        let worker_id = seed_sub(&ws, Some("reviewer"));
745
746        ws.remove_gotify_subscriptions_for_worker(&key, "reviewer");
747
748        let remaining: Vec<Uuid> =
749            ws.gotify_subscriptions_for_project("myproj").iter().map(|s| s.id).collect();
750        assert!(!remaining.contains(&worker_id), "teardown removed the departing worker's");
751        assert!(remaining.contains(&lead_id), "the lead's subscription survives the despawn");
752    }
753
754    #[test]
755    fn worker_is_durable_when_persisted() {
756        let dynamic = vec!["scratch".to_owned()];
757        assert_eq!(durable_identity(Some("scratch"), &dynamic), (Some("scratch".to_owned()), true),);
758    }
759
760    #[test]
761    fn lead_is_durable_and_targets_itself() {
762        assert_eq!(durable_identity(None, &[]), (None, true));
763    }
764
765    #[test]
766    fn worker_without_a_row_is_ephemeral() {
767        // No row means nothing re-spawns the label, so its subscription is
768        // in-memory only rather than written to redb for an absent owner.
769        assert_eq!(
770            durable_identity(Some("scratch"), &["reviewer".to_owned()]),
771            (Some("scratch".to_owned()), false),
772        );
773    }
774}