/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 | | } |