Skip to main content

gonzalo_ticket/
conformance.rs

1//! Reusable conformance checks for `TicketSource` implementations.
2//!
3//! The ticket analogue of `gonzalo-core`'s substrate conformance suite
4//! (ADR 0006/0010): the executable definition of what every connector must do.
5//! Connectors run these in their own tests against **recorded fixtures**
6//! (wiremock), keyed on policy variants where a platform's status signal is
7//! configurable (e.g. GitLab scoped-label vs intrinsic, Asana section vs
8//! completed). These helpers are provider-agnostic — they assert the invariants
9//! that must hold for any imported ticket and any source.
10
11use crate::{SourceError, TicketSource};
12use gonzalo_domain::{StateCategory, Ticket};
13
14/// Invariants every imported ticket must satisfy, regardless of provider.
15///
16/// Panics (test-style) on violation so connectors can call it directly in a
17/// `#[tokio::test]`.
18pub fn assert_ticket_invariants(t: &Ticket) {
19    assert!(!t.uid.is_empty(), "ticket uid must be non-empty");
20    assert!(!t.display.is_empty(), "ticket display must be non-empty");
21    assert!(
22        !t.item_type.is_empty(),
23        "ticket item_type must be non-empty"
24    );
25    assert!(
26        !t.state.raw_name.is_empty(),
27        "state.raw_name must be retained for fidelity"
28    );
29    let primaries = t.containers.iter().filter(|c| c.primary).count();
30    assert!(
31        primaries <= 1,
32        "at most one primary container is allowed, found {primaries}"
33    );
34}
35
36/// A source must honor its declared write capability: if `capabilities().push`
37/// is false, `set_state` must fail with [`SourceError::Unsupported`] rather than
38/// silently no-op or panic.
39pub async fn assert_write_gating<S: TicketSource + Sync>(source: &S, uid: &str) {
40    if source.capabilities().push {
41        return;
42    }
43    let err = source.set_state(uid, StateCategory::Done).await.err();
44    assert!(
45        matches!(err, Some(SourceError::Unsupported(_))),
46        "a read-only source must reject set_state with Unsupported, got {err:?}"
47    );
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::InMemorySource;
54    use gonzalo_domain::{BodyFormat, Container, Provider, State, TicketBody};
55    use std::collections::BTreeMap;
56
57    fn ticket() -> Ticket {
58        Ticket {
59            provider: Provider::GitHub,
60            uid: "o/r#1".into(),
61            display: "#1".into(),
62            item_type: "issue".into(),
63            title: "t".into(),
64            state: State {
65                category: StateCategory::Open,
66                resolution: None,
67                raw_name: "open".into(),
68                raw_id: None,
69            },
70            priority: None,
71            actors: vec![],
72            labels: vec![],
73            containers: vec![Container {
74                kind: "repo".into(),
75                id: "o/r".into(),
76                name: None,
77                primary: true,
78            }],
79            links: vec![],
80            body: TicketBody {
81                markdown: String::new(),
82                format: BodyFormat::Markdown,
83                raw: None,
84            },
85            fields: BTreeMap::new(),
86        }
87    }
88
89    #[test]
90    fn invariants_pass_for_a_well_formed_ticket() {
91        assert_ticket_invariants(&ticket());
92    }
93
94    #[test]
95    #[should_panic(expected = "uid must be non-empty")]
96    fn invariants_catch_empty_uid() {
97        let mut t = ticket();
98        t.uid = String::new();
99        assert_ticket_invariants(&t);
100    }
101
102    #[tokio::test]
103    async fn write_gating_holds_for_read_only_source() {
104        let src = InMemorySource::new(vec![ticket()]);
105        assert_write_gating(&src, "o/r#1").await;
106    }
107}