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/graph_layout.rs
Line
Count
Source
1
//! Graph layout override model for zoom-level specific rendering state.
2
//!
3
//! Stores per-view layout metadata (nodes, edges, viewport) independently from
4
//! canonical architecture entities and raw connections.
5
6
use std::collections::HashMap;
7
8
use aws_sdk_dynamodb::types::AttributeValue;
9
use chrono::{DateTime, Utc};
10
use serde::{Deserialize, Serialize};
11
use serde_dynamo::{from_item, to_item};
12
13
use crate::{
14
    ApiError,
15
    db::insert_entity_type,
16
    models::{
17
        connection::{ConnectionAnchorSide, ConnectionControlPoint, ConnectionRoutingMode},
18
        organisation::OrganisationId,
19
        project::ProjectId,
20
    },
21
};
22
23
pub const MAX_LAYOUT_NODE_OVERRIDES: usize = 5000;
24
pub const MAX_LAYOUT_EDGE_OVERRIDES: usize = 10_000;
25
const MAX_LAYOUT_KEY_LENGTH: usize = 256;
26
const MAX_LAYOUT_CONTROL_POINTS: usize = 16;
27
28
pub use tenet_domain::GraphViewLevel;
29
30
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
31
pub struct GraphLayoutNodeOverride {
32
    pub x: f64,
33
    pub y: f64,
34
}
35
36
impl GraphLayoutNodeOverride {
37
5
    pub fn validate(&self) -> Result<(), ApiError> {
38
5
        if !self.x.is_finite() || 
!self.y.is_finite()4
{
39
1
            return Err(ApiError::InvalidRequest(
40
1
                "Graph layout node coordinates must be finite".to_string(),
41
1
            ));
42
4
        }
43
4
        Ok(())
44
5
    }
45
}
46
47
#[derive(Debug, Clone, Serialize, Deserialize)]
48
pub struct GraphLayoutEdgeOverride {
49
    #[serde(skip_serializing_if = "Option::is_none")]
50
    pub source_side: Option<ConnectionAnchorSide>,
51
    #[serde(skip_serializing_if = "Option::is_none")]
52
    pub target_side: Option<ConnectionAnchorSide>,
53
    #[serde(skip_serializing_if = "Option::is_none")]
54
    pub routing_mode: Option<ConnectionRoutingMode>,
55
    #[serde(skip_serializing_if = "Option::is_none")]
56
    pub control_points: Option<Vec<ConnectionControlPoint>>,
57
}
58
59
impl GraphLayoutEdgeOverride {
60
7
    pub fn validate(&self) -> Result<(), ApiError> {
61
7
        if let Some(
points5
) = &self.control_points {
62
5
            if points.len() > MAX_LAYOUT_CONTROL_POINTS {
63
1
                return Err(ApiError::InvalidRequest(format!(
64
1
                    "Graph layout edge control points must not exceed {}",
65
1
                    MAX_LAYOUT_CONTROL_POINTS
66
1
                )));
67
4
            }
68
4
            let has_non_finite = points
69
4
                .iter()
70
5
                .
any4
(|point| !point.x.is_finite() ||
!point.y.is_finite()4
);
71
4
            if has_non_finite {
72
1
                return Err(ApiError::InvalidRequest(
73
1
                    "Graph layout control points must have finite coordinates".to_string(),
74
1
                ));
75
3
            }
76
2
        }
77
5
        Ok(())
78
7
    }
79
}
80
81
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
82
pub struct GraphLayoutViewport {
83
    pub x: f64,
84
    pub y: f64,
85
    pub zoom: f64,
86
}
87
88
impl GraphLayoutViewport {
89
4
    pub fn validate(&self) -> Result<(), ApiError> {
90
4
        if !self.x.is_finite() || !self.y.is_finite() || !self.zoom.is_finite() {
91
0
            return Err(ApiError::InvalidRequest(
92
0
                "Graph layout viewport values must be finite".to_string(),
93
0
            ));
94
4
        }
95
4
        if self.zoom <= 0.0 {
96
0
            return Err(ApiError::InvalidRequest(
97
0
                "Graph layout viewport zoom must be greater than 0".to_string(),
98
0
            ));
99
4
        }
100
4
        Ok(())
101
4
    }
102
}
103
104
#[derive(Debug, Clone, Serialize, Deserialize)]
105
pub struct GraphLayoutView {
106
    pub organisation_id: OrganisationId,
107
    pub project_id: ProjectId,
108
    pub view_level: GraphViewLevel,
109
    #[serde(default)]
110
    pub version: u64,
111
    #[serde(default)]
112
    pub nodes: HashMap<String, GraphLayoutNodeOverride>,
113
    #[serde(default)]
114
    pub edges: HashMap<String, GraphLayoutEdgeOverride>,
115
    #[serde(skip_serializing_if = "Option::is_none")]
116
    pub viewport: Option<GraphLayoutViewport>,
117
    pub created_at: DateTime<Utc>,
118
    pub updated_at: DateTime<Utc>,
119
}
120
121
impl GraphLayoutView {
122
4
    pub fn new(
123
4
        organisation_id: OrganisationId,
124
4
        project_id: ProjectId,
125
4
        view_level: GraphViewLevel,
126
4
    ) -> Self {
127
4
        let now = Utc::now();
128
4
        Self {
129
4
            organisation_id,
130
4
            project_id,
131
4
            view_level,
132
4
            version: 0,
133
4
            nodes: HashMap::new(),
134
4
            edges: HashMap::new(),
135
4
            viewport: None,
136
4
            created_at: now,
137
4
            updated_at: now,
138
4
        }
139
4
    }
140
141
2
    pub fn validate(&self) -> Result<(), ApiError> {
142
2
        validate_override_keys(&self.nodes)
?1
;
143
1
        validate_override_keys(&self.edges)
?0
;
144
145
1
        if self.nodes.len() > MAX_LAYOUT_NODE_OVERRIDES {
146
0
            return Err(ApiError::InvalidRequest(format!(
147
0
                "Graph layout node overrides must not exceed {}",
148
0
                MAX_LAYOUT_NODE_OVERRIDES
149
0
            )));
150
1
        }
151
152
1
        if self.edges.len() > MAX_LAYOUT_EDGE_OVERRIDES {
153
0
            return Err(ApiError::InvalidRequest(format!(
154
0
                "Graph layout edge overrides must not exceed {}",
155
0
                MAX_LAYOUT_EDGE_OVERRIDES
156
0
            )));
157
1
        }
158
159
1
        for node_override in self.nodes.values() {
160
1
            node_override.validate()
?0
;
161
        }
162
1
        for edge_override in self.edges.values() {
163
1
            edge_override.validate()
?0
;
164
        }
165
1
        if let Some(viewport) = self.viewport {
166
1
            viewport.validate()
?0
;
167
0
        }
168
1
        Ok(())
169
2
    }
170
}
171
172
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173
pub struct PutGraphLayoutRequest {
174
    #[serde(skip_serializing_if = "Option::is_none")]
175
    pub expected_version: Option<u64>,
176
    #[serde(default)]
177
    pub nodes: HashMap<String, GraphLayoutNodeOverride>,
178
    #[serde(default)]
179
    pub edges: HashMap<String, GraphLayoutEdgeOverride>,
180
    #[serde(skip_serializing_if = "Option::is_none")]
181
    pub viewport: Option<GraphLayoutViewport>,
182
}
183
184
impl PutGraphLayoutRequest {
185
1
    pub fn validate(&self) -> Result<(), ApiError> {
186
1
        validate_override_keys(&self.nodes)
?0
;
187
1
        validate_override_keys(&self.edges)
?0
;
188
189
1
        if self.nodes.len() > MAX_LAYOUT_NODE_OVERRIDES {
190
0
            return Err(ApiError::InvalidRequest(format!(
191
0
                "Graph layout node overrides must not exceed {}",
192
0
                MAX_LAYOUT_NODE_OVERRIDES
193
0
            )));
194
1
        }
195
1
        if self.edges.len() > MAX_LAYOUT_EDGE_OVERRIDES {
196
0
            return Err(ApiError::InvalidRequest(format!(
197
0
                "Graph layout edge overrides must not exceed {}",
198
0
                MAX_LAYOUT_EDGE_OVERRIDES
199
0
            )));
200
1
        }
201
202
1
        for node in self.nodes.values() {
203
1
            node.validate()
?0
;
204
        }
205
1
        for edge in self.edges.values() {
206
1
            edge.validate()
?0
;
207
        }
208
1
        if let Some(viewport) = self.viewport {
209
1
            viewport.validate()
?0
;
210
0
        }
211
1
        Ok(())
212
1
    }
213
}
214
215
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216
pub struct PatchGraphLayoutRequest {
217
    #[serde(skip_serializing_if = "Option::is_none")]
218
    pub expected_version: Option<u64>,
219
    #[serde(skip_serializing_if = "Option::is_none")]
220
    pub nodes_upsert: Option<HashMap<String, GraphLayoutNodeOverride>>,
221
    #[serde(skip_serializing_if = "Option::is_none")]
222
    pub nodes_remove: Option<Vec<String>>,
223
    #[serde(skip_serializing_if = "Option::is_none")]
224
    pub edges_upsert: Option<HashMap<String, GraphLayoutEdgeOverride>>,
225
    #[serde(skip_serializing_if = "Option::is_none")]
226
    pub edges_remove: Option<Vec<String>>,
227
    #[serde(skip_serializing_if = "Option::is_none")]
228
    pub viewport: Option<GraphLayoutViewport>,
229
    #[serde(skip_serializing_if = "Option::is_none")]
230
    pub clear_viewport: Option<bool>,
231
}
232
233
impl PatchGraphLayoutRequest {
234
3
    pub fn validate(&self) -> Result<(), ApiError> {
235
3
        if let Some(nodes_upsert) = &self.nodes_upsert {
236
3
            validate_override_keys(nodes_upsert)
?0
;
237
3
            if nodes_upsert.len() > MAX_LAYOUT_NODE_OVERRIDES {
238
0
                return Err(ApiError::InvalidRequest(format!(
239
0
                    "Graph layout node overrides in patch must not exceed {}",
240
0
                    MAX_LAYOUT_NODE_OVERRIDES
241
0
                )));
242
3
            }
243
3
            for node_override in nodes_upsert.values() {
244
3
                node_override.validate()
?1
;
245
            }
246
0
        }
247
248
2
        if let Some(nodes_remove) = &self.nodes_remove {
249
2
            for key in nodes_remove {
250
2
                validate_override_key(key)
?0
;
251
            }
252
0
        }
253
254
2
        if let Some(edges_upsert) = &self.edges_upsert {
255
2
            validate_override_keys(edges_upsert)
?0
;
256
2
            if edges_upsert.len() > MAX_LAYOUT_EDGE_OVERRIDES {
257
0
                return Err(ApiError::InvalidRequest(format!(
258
0
                    "Graph layout edge overrides in patch must not exceed {}",
259
0
                    MAX_LAYOUT_EDGE_OVERRIDES
260
0
                )));
261
2
            }
262
2
            for edge_override in edges_upsert.values() {
263
2
                edge_override.validate()
?0
;
264
            }
265
0
        }
266
267
2
        if let Some(edges_remove) = &self.edges_remove {
268
2
            for key in edges_remove {
269
2
                validate_override_key(key)
?0
;
270
            }
271
0
        }
272
273
2
        if let Some(viewport) = self.viewport {
274
2
            viewport.validate()
?0
;
275
0
        }
276
277
2
        if self.clear_viewport.unwrap_or(false) && 
self.viewport1
.
is_some1
() {
278
1
            return Err(ApiError::InvalidRequest(
279
1
                "Graph layout patch cannot set viewport and clear_viewport in same request"
280
1
                    .to_string(),
281
1
            ));
282
1
        }
283
284
1
        Ok(())
285
3
    }
286
}
287
288
10
fn validate_override_keys<T>(overrides: &HashMap<String, T>) -> Result<(), ApiError> {
289
10
    for key in overrides.keys() {
290
10
        validate_override_key(key)
?1
;
291
    }
292
9
    Ok(())
293
10
}
294
295
14
fn validate_override_key(key: &str) -> Result<(), ApiError> {
296
14
    if key.trim().is_empty() {
297
1
        return Err(ApiError::InvalidRequest(
298
1
            "Graph layout override keys must not be empty".to_string(),
299
1
        ));
300
13
    }
301
13
    if key.len() > MAX_LAYOUT_KEY_LENGTH {
302
0
        return Err(ApiError::InvalidRequest(format!(
303
0
            "Graph layout override key length must not exceed {}",
304
0
            MAX_LAYOUT_KEY_LENGTH
305
0
        )));
306
13
    }
307
13
    Ok(())
308
14
}
309
310
impl TryFrom<&GraphLayoutView> for HashMap<String, AttributeValue> {
311
    type Error = ApiError;
312
313
1
    fn try_from(value: &GraphLayoutView) -> Result<Self, Self::Error> {
314
1
        let item = to_item(value).map_err(|error| ApiError::InvalidRequest(
error0
.
to_string0
()))
?0
;
315
1
        Ok(insert_entity_type(item, "GraphLayoutView"))
316
1
    }
317
}
318
319
impl TryFrom<&HashMap<String, AttributeValue>> for GraphLayoutView {
320
    type Error = ApiError;
321
322
1
    fn try_from(value: &HashMap<String, AttributeValue>) -> Result<Self, Self::Error> {
323
1
        from_item(value.clone()).map_err(|error| ApiError::InvalidRequest(
error0
.
to_string0
()))
324
1
    }
325
}
326
327
impl TryFrom<HashMap<String, AttributeValue>> for GraphLayoutView {
328
    type Error = ApiError;
329
330
0
    fn try_from(value: HashMap<String, AttributeValue>) -> Result<Self, Self::Error> {
331
0
        GraphLayoutView::try_from(&value)
332
0
    }
333
}
334
335
#[cfg(test)]
336
mod tests {
337
    use std::str::FromStr;
338
339
    use super::*;
340
    use crate::test_support::must_ok;
341
342
    #[allure_rs::allure_parent_suite("tenet-aws")]
343
    #[allure_rs::allure_test]
344
    #[test]
345
    fn test_graph_view_level_roundtrip() {
346
        let level: GraphViewLevel = must_ok(serde_json::from_str("\"context\""));
347
        assert_eq!(level, GraphViewLevel::Context);
348
        assert_eq!(level.to_string(), "context");
349
        assert!(GraphViewLevel::from_str("invalid").is_err());
350
    }
351
352
    #[allure_rs::allure_parent_suite("tenet-aws")]
353
    #[allure_rs::allure_test]
354
    #[test]
355
    fn test_graph_layout_edge_override_validate() {
356
        let valid = GraphLayoutEdgeOverride {
357
            source_side: Some(ConnectionAnchorSide::Right),
358
            target_side: Some(ConnectionAnchorSide::Left),
359
            routing_mode: Some(ConnectionRoutingMode::Manual),
360
            control_points: Some(vec![
361
                ConnectionControlPoint { x: 10.0, y: 20.0 },
362
                ConnectionControlPoint { x: 30.0, y: 40.0 },
363
            ]),
364
        };
365
        assert!(valid.validate().is_ok());
366
367
        let too_many = GraphLayoutEdgeOverride {
368
            source_side: None,
369
            target_side: None,
370
            routing_mode: None,
371
            control_points: Some(
372
                (0..=MAX_LAYOUT_CONTROL_POINTS)
373
                    .map(|index| ConnectionControlPoint {
374
17
                        x: index as f64,
375
17
                        y: index as f64,
376
17
                    })
377
                    .collect(),
378
            ),
379
        };
380
        assert!(too_many.validate().is_err());
381
382
        let non_finite = GraphLayoutEdgeOverride {
383
            source_side: None,
384
            target_side: None,
385
            routing_mode: None,
386
            control_points: Some(vec![ConnectionControlPoint {
387
                x: f64::INFINITY,
388
                y: 0.0,
389
            }]),
390
        };
391
        assert!(non_finite.validate().is_err());
392
    }
393
394
    #[allure_rs::allure_parent_suite("tenet-aws")]
395
    #[allure_rs::allure_test]
396
    #[test]
397
    fn test_graph_layout_view_validate_keys_counts_and_values() {
398
        let mut layout = GraphLayoutView::new(
399
            must_ok(
400
                "550e8400-e29b-41d4-a716-446655440000"
401
                    .to_string()
402
                    .try_into(),
403
            ),
404
            must_ok(ProjectId::try_from("11111111-1111-4111-8111-111111111111")),
405
            GraphViewLevel::Container,
406
        );
407
        layout.nodes.insert(
408
            "container:frontend".to_string(),
409
            GraphLayoutNodeOverride { x: 10.0, y: 20.0 },
410
        );
411
        layout.edges.insert(
412
            "inferred:container:frontend->api".to_string(),
413
            GraphLayoutEdgeOverride {
414
                source_side: Some(ConnectionAnchorSide::Bottom),
415
                target_side: Some(ConnectionAnchorSide::Top),
416
                routing_mode: Some(ConnectionRoutingMode::Auto),
417
                control_points: None,
418
            },
419
        );
420
        layout.viewport = Some(GraphLayoutViewport {
421
            x: 0.0,
422
            y: 0.0,
423
            zoom: 1.2,
424
        });
425
        assert!(layout.validate().is_ok());
426
427
        layout
428
            .nodes
429
            .insert("".to_string(), GraphLayoutNodeOverride { x: 0.0, y: 0.0 });
430
        assert!(layout.validate().is_err());
431
    }
432
433
    #[allure_rs::allure_parent_suite("tenet-aws")]
434
    #[allure_rs::allure_test]
435
    #[test]
436
    fn test_patch_graph_layout_request_validate_conflict_and_keys() {
437
        let mut patch = PatchGraphLayoutRequest {
438
            expected_version: Some(2),
439
            nodes_upsert: Some(HashMap::from([(
440
                "node:abc".to_string(),
441
                GraphLayoutNodeOverride { x: 1.0, y: 2.0 },
442
            )])),
443
            nodes_remove: Some(vec!["node:def".to_string()]),
444
            edges_upsert: Some(HashMap::from([(
445
                "inferred:context:project->store".to_string(),
446
                GraphLayoutEdgeOverride {
447
                    source_side: Some(ConnectionAnchorSide::Right),
448
                    target_side: Some(ConnectionAnchorSide::Left),
449
                    routing_mode: Some(ConnectionRoutingMode::Manual),
450
                    control_points: Some(vec![ConnectionControlPoint { x: 3.0, y: 4.0 }]),
451
                },
452
            )])),
453
            edges_remove: Some(vec!["connection:123".to_string()]),
454
            viewport: Some(GraphLayoutViewport {
455
                x: 0.0,
456
                y: 0.0,
457
                zoom: 1.0,
458
            }),
459
            clear_viewport: Some(false),
460
        };
461
        assert!(patch.validate().is_ok());
462
463
        patch.clear_viewport = Some(true);
464
        assert!(patch.validate().is_err());
465
    }
466
467
    #[allure_rs::allure_parent_suite("tenet-aws")]
468
    #[allure_rs::allure_test]
469
    #[test]
470
    fn test_patch_graph_layout_request_validate_rejects_non_finite() {
471
        let patch = PatchGraphLayoutRequest {
472
            expected_version: None,
473
            nodes_upsert: Some(HashMap::from([(
474
                "node:abc".to_string(),
475
                GraphLayoutNodeOverride {
476
                    x: f64::NAN,
477
                    y: 0.0,
478
                },
479
            )])),
480
            nodes_remove: None,
481
            edges_upsert: None,
482
            edges_remove: None,
483
            viewport: None,
484
            clear_viewport: None,
485
        };
486
        assert!(patch.validate().is_err());
487
    }
488
489
    #[allure_rs::allure_parent_suite("tenet-aws")]
490
    #[allure_rs::allure_test]
491
    #[test]
492
    fn test_graph_layout_roundtrip_item_conversion() {
493
        let mut layout = GraphLayoutView::new(
494
            must_ok(
495
                "550e8400-e29b-41d4-a716-446655440000"
496
                    .to_string()
497
                    .try_into(),
498
            ),
499
            must_ok(ProjectId::try_from("11111111-1111-4111-8111-111111111111")),
500
            GraphViewLevel::Context,
501
        );
502
        layout.version = 3;
503
        layout.nodes.insert(
504
            "project".to_string(),
505
            GraphLayoutNodeOverride { x: 10.0, y: 20.0 },
506
        );
507
        layout.edges.insert(
508
            "inferred:context:project->store".to_string(),
509
            GraphLayoutEdgeOverride {
510
                source_side: Some(ConnectionAnchorSide::Bottom),
511
                target_side: Some(ConnectionAnchorSide::Top),
512
                routing_mode: Some(ConnectionRoutingMode::Auto),
513
                control_points: None,
514
            },
515
        );
516
        layout.viewport = Some(GraphLayoutViewport {
517
            x: 1.0,
518
            y: 2.0,
519
            zoom: 1.4,
520
        });
521
522
        let item = must_ok(HashMap::<String, AttributeValue>::try_from(&layout));
523
        let decoded = must_ok(GraphLayoutView::try_from(&item));
524
525
        assert_eq!(decoded.view_level, GraphViewLevel::Context);
526
        assert_eq!(decoded.version, 3);
527
        assert_eq!(decoded.nodes.len(), 1);
528
        assert_eq!(decoded.edges.len(), 1);
529
        assert!(decoded.viewport.is_some());
530
    }
531
532
    #[allure_rs::allure_parent_suite("tenet-aws")]
533
    #[allure_rs::allure_test]
534
    #[test]
535
    fn test_put_graph_layout_request_validate() {
536
        let put = PutGraphLayoutRequest {
537
            expected_version: Some(1),
538
            nodes: HashMap::from([(
539
                "node:abc".to_string(),
540
                GraphLayoutNodeOverride { x: 1.0, y: 2.0 },
541
            )]),
542
            edges: HashMap::from([(
543
                "edge:abc".to_string(),
544
                GraphLayoutEdgeOverride {
545
                    source_side: None,
546
                    target_side: None,
547
                    routing_mode: Some(ConnectionRoutingMode::Auto),
548
                    control_points: None,
549
                },
550
            )]),
551
            viewport: Some(GraphLayoutViewport {
552
                x: 0.0,
553
                y: 0.0,
554
                zoom: 1.0,
555
            }),
556
        };
557
        assert!(put.validate().is_ok());
558
    }
559
}