Coverage Report

Created: 2026-03-25 23:22

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/tenet/tenet/tenet-aws/src/models/external_system.rs
Line
Count
Source
1
//! External system model for tenet
2
//!
3
//! Represents a system outside our ownership/bounded context that provides
4
//! capabilities or information (not persistence for our domain).
5
6
use chrono::{DateTime, Utc};
7
use serde::{Deserialize, Serialize};
8
9
use crate::{
10
    ApiError,
11
    clock::{Clock, SystemClock},
12
    models::{
13
        graph::NodeRecord,
14
        id::{IdGenerator, UuidGenerator, UuidValidation},
15
        organisation::OrganisationId,
16
        project::ProjectId,
17
        unified_component::UnifiedComponent,
18
    },
19
};
20
21
// =============================================================================
22
// ExternalSystemId Newtype
23
// =============================================================================
24
25
crate::define_uuid_id!(
26
    ExternalSystemId,
27
    "Invalid External System Id",
28
    UuidValidation::V4
29
);
30
31
// =============================================================================
32
// External System Model
33
// =============================================================================
34
35
/// Represents an external system within a project
36
#[derive(Debug, Clone, Serialize, Deserialize)]
37
pub struct ExternalSystem {
38
    /// Unique identifier (UUID)
39
    pub id: ExternalSystemId,
40
    /// Project this external system belongs to
41
    pub project_id: ProjectId,
42
    /// Organisation this external system belongs to (denormalized)
43
    pub organisation_id: OrganisationId,
44
    /// Display name
45
    pub name: String,
46
    /// Optional description
47
    #[serde(skip_serializing_if = "Option::is_none")]
48
    pub description: Option<String>,
49
    /// Category (e.g., payment, auth, crm, email, partner)
50
    pub category: String,
51
    /// Optional vendor (e.g., Stripe, Okta)
52
    #[serde(skip_serializing_if = "Option::is_none")]
53
    pub vendor: Option<String>,
54
    /// Optional tags
55
    #[serde(skip_serializing_if = "Option::is_none")]
56
    pub tags: Option<Vec<String>>,
57
    /// X position in visual editor
58
    #[serde(skip_serializing_if = "Option::is_none")]
59
    pub position_x: Option<f64>,
60
    /// Y position in visual editor
61
    #[serde(skip_serializing_if = "Option::is_none")]
62
    pub position_y: Option<f64>,
63
    /// When the external system was created
64
    pub created_at: DateTime<Utc>,
65
    /// When the external system was last updated
66
    pub updated_at: DateTime<Utc>,
67
}
68
69
impl ExternalSystem {
70
    /// Create a new ExternalSystem
71
9
    pub fn new(
72
9
        project_id: ProjectId,
73
9
        organisation_id: OrganisationId,
74
9
        name: String,
75
9
        description: Option<String>,
76
9
        category: String,
77
9
        vendor: Option<String>,
78
9
        tags: Option<Vec<String>>,
79
9
    ) -> Self {
80
9
        Self::new_with_clock_and_generator(
81
9
            &SystemClock,
82
9
            &UuidGenerator,
83
9
            project_id,
84
9
            organisation_id,
85
9
            name,
86
9
            description,
87
9
            category,
88
9
            vendor,
89
9
            tags,
90
        )
91
9
    }
92
93
9
    pub fn new_with_clock_and_generator<C: Clock, G: IdGenerator>(
94
9
        clock: &C,
95
9
        id_generator: &G,
96
9
        project_id: ProjectId,
97
9
        organisation_id: OrganisationId,
98
9
        name: String,
99
9
        description: Option<String>,
100
9
        category: String,
101
9
        vendor: Option<String>,
102
9
        tags: Option<Vec<String>>,
103
9
    ) -> Self {
104
9
        let now = clock.now();
105
9
        Self {
106
9
            id: ExternalSystemId::new_with_generator(id_generator),
107
9
            project_id,
108
9
            organisation_id,
109
9
            name,
110
9
            description,
111
9
            category,
112
9
            vendor,
113
9
            tags,
114
9
            position_x: None,
115
9
            position_y: None,
116
9
            created_at: now,
117
9
            updated_at: now,
118
9
        }
119
9
    }
120
}
121
122
impl TryFrom<&ExternalSystem> for NodeRecord {
123
    type Error = ApiError;
124
125
3
    fn try_from(value: &ExternalSystem) -> Result<Self, Self::Error> {
126
3
        let unified = UnifiedComponent::try_from(value)
?0
;
127
3
        NodeRecord::try_from(&unified)
128
3
    }
129
}
130
131
impl TryFrom<&NodeRecord> for ExternalSystem {
132
    type Error = ApiError;
133
134
3
    fn try_from(value: &NodeRecord) -> Result<Self, Self::Error> {
135
3
        crate::models::unified_graph_node_codec::decode_external_system_from_node(value)
136
3
    }
137
}
138
139
impl TryFrom<NodeRecord> for ExternalSystem {
140
    type Error = ApiError;
141
142
0
    fn try_from(value: NodeRecord) -> Result<Self, Self::Error> {
143
0
        ExternalSystem::try_from(&value)
144
0
    }
145
}
146
147
// =============================================================================
148
// View Types (for API responses)
149
// =============================================================================
150
151
/// Simplified external system view for API responses
152
#[derive(Debug, Clone, Serialize, Deserialize)]
153
pub struct ExternalSystemView {
154
    pub id: ExternalSystemId,
155
    pub project_id: ProjectId,
156
    pub name: String,
157
    #[serde(skip_serializing_if = "Option::is_none")]
158
    pub description: Option<String>,
159
    pub category: String,
160
    #[serde(skip_serializing_if = "Option::is_none")]
161
    pub vendor: Option<String>,
162
    #[serde(skip_serializing_if = "Option::is_none")]
163
    pub tags: Option<Vec<String>>,
164
    #[serde(skip_serializing_if = "Option::is_none")]
165
    pub position_x: Option<f64>,
166
    #[serde(skip_serializing_if = "Option::is_none")]
167
    pub position_y: Option<f64>,
168
    pub created_at: DateTime<Utc>,
169
    pub updated_at: DateTime<Utc>,
170
}
171
172
impl From<&ExternalSystem> for ExternalSystemView {
173
2
    fn from(system: &ExternalSystem) -> Self {
174
2
        Self {
175
2
            id: system.id.clone(),
176
2
            project_id: system.project_id.clone(),
177
2
            name: system.name.clone(),
178
2
            description: system.description.clone(),
179
2
            category: system.category.clone(),
180
2
            vendor: system.vendor.clone(),
181
2
            tags: system.tags.clone(),
182
2
            position_x: system.position_x,
183
2
            position_y: system.position_y,
184
2
            created_at: system.created_at,
185
2
            updated_at: system.updated_at,
186
2
        }
187
2
    }
188
}
189
190
// =============================================================================
191
// Request Types
192
// =============================================================================
193
194
/// Request to create a new external system
195
#[derive(Debug, Clone, Serialize, Deserialize)]
196
pub struct CreateExternalSystemRequest {
197
    pub name: String,
198
    #[serde(skip_serializing_if = "Option::is_none")]
199
    pub description: Option<String>,
200
    pub category: String,
201
    #[serde(skip_serializing_if = "Option::is_none")]
202
    pub vendor: Option<String>,
203
    #[serde(skip_serializing_if = "Option::is_none")]
204
    pub tags: Option<Vec<String>>,
205
    #[serde(skip_serializing_if = "Option::is_none")]
206
    pub position_x: Option<f64>,
207
    #[serde(skip_serializing_if = "Option::is_none")]
208
    pub position_y: Option<f64>,
209
}
210
211
/// Request to update an existing external system
212
#[derive(Debug, Clone, Serialize, Deserialize)]
213
pub struct UpdateExternalSystemRequest {
214
    #[serde(skip_serializing_if = "Option::is_none")]
215
    pub name: Option<String>,
216
    #[serde(skip_serializing_if = "Option::is_none")]
217
    pub description: Option<String>,
218
    #[serde(skip_serializing_if = "Option::is_none")]
219
    pub category: Option<String>,
220
    #[serde(skip_serializing_if = "Option::is_none")]
221
    pub vendor: Option<String>,
222
    #[serde(skip_serializing_if = "Option::is_none")]
223
    pub tags: Option<Vec<String>>,
224
    #[serde(skip_serializing_if = "Option::is_none")]
225
    pub position_x: Option<f64>,
226
    #[serde(skip_serializing_if = "Option::is_none")]
227
    pub position_y: Option<f64>,
228
}
229
230
#[cfg(test)]
231
mod tests {
232
    use super::*;
233
    use crate::models::{organisation::OrganisationId, project::ProjectId};
234
    use crate::test_support::must_ok;
235
4
    fn org_id() -> OrganisationId {
236
4
        must_ok(OrganisationId::try_from(uuid::Uuid::new_v4().to_string()))
237
4
    }
238
239
4
    fn project_id() -> ProjectId {
240
4
        must_ok(ProjectId::try_from(uuid::Uuid::new_v4().to_string()))
241
4
    }
242
243
    #[allure_rs::allure_test]
244
    #[test]
245
1
    fn test_external_system_roundtrip_node_record() {
246
1
        let mut system = ExternalSystem::new(
247
1
            project_id(),
248
1
            org_id(),
249
1
            "GitHub".to_string(),
250
1
            Some("Source control".to_string()),
251
1
            "developer".to_string(),
252
1
            Some("GitHub".to_string()),
253
1
            Some(vec!["code".to_string()]),
254
        );
255
1
        system.position_x = Some(5.0);
256
1
        system.position_y = Some(6.0);
257
258
1
        let node = must_ok(NodeRecord::try_from(&system));
259
1
        assert_eq!(node.node_type.as_str(), "external_system");
260
1
        let decoded = must_ok(ExternalSystem::try_from(&node));
261
1
        assert_eq!(decoded.id, system.id);
262
1
        assert_eq!(decoded.name, "GitHub");
263
1
        assert_eq!(decoded.position_y, Some(6.0));
264
    }
265
266
    #[allure_rs::allure_test]
267
    #[test]
268
1
    fn test_external_system_view_from() {
269
1
        let system = ExternalSystem::new(
270
1
            project_id(),
271
1
            org_id(),
272
1
            "Stripe".to_string(),
273
1
            None,
274
1
            "payment".to_string(),
275
1
            None,
276
1
            None,
277
        );
278
1
        let view = ExternalSystemView::from(&system);
279
1
        assert_eq!(view.id, system.id);
280
1
        assert_eq!(view.category, "payment");
281
    }
282
283
    #[allure_rs::allure_test]
284
    #[test]
285
1
    fn test_external_system_decode_requires_properties() {
286
1
        let system = ExternalSystem::new(
287
1
            project_id(),
288
1
            org_id(),
289
1
            "Mail".to_string(),
290
1
            None,
291
1
            "email".to_string(),
292
1
            None,
293
1
            None,
294
        );
295
1
        let mut node = must_ok(NodeRecord::try_from(&system));
296
1
        node.properties = None;
297
1
        assert!(ExternalSystem::try_from(&node).is_err());
298
    }
299
300
    #[allure_rs::allure_test]
301
    #[test]
302
1
    fn test_external_system_decode_rejects_legacy_flat_properties_shape() {
303
1
        let system = ExternalSystem::new(
304
1
            project_id(),
305
1
            org_id(),
306
1
            "GitHub".to_string(),
307
1
            Some("Source control".to_string()),
308
1
            "developer".to_string(),
309
1
            Some("GitHub".to_string()),
310
1
            Some(vec!["code".to_string()]),
311
        );
312
1
        let mut node = must_ok(NodeRecord::try_from(&system));
313
1
        node.properties = Some(serde_json::json!({
314
1
            "category": "developer",
315
1
            "vendor": "GitHub",
316
1
            "tags": ["code"],
317
1
            "position_x": 10.0,
318
1
            "position_y": 20.0
319
1
        }));
320
321
1
        let err = ExternalSystem::try_from(&node).expect_err("legacy properties must be rejected");
322
1
        let err_text = err.to_string();
323
1
        assert!(
324
1
            err_text.contains("Failed to decode unified component properties"),
325
            "unexpected error: {err_text}"
326
        );
327
    }
328
}