/home/runner/work/tenet/tenet/tenet-aws/src/services/quality_attribute.rs
Line | Count | Source |
1 | | //! Quality attribute service for business logic operations |
2 | | //! |
3 | | //! Handles quality attribute management with authorization and validation. |
4 | | |
5 | | use std::sync::Arc; |
6 | | |
7 | | use crate::{ |
8 | | ApiError, |
9 | | db::{ |
10 | | TenetDatabase, |
11 | | project::ProjectRepository, |
12 | | quality_attribute::QualityAttributeRepository, |
13 | | quality_component_link::QualityComponentLinkRepository, |
14 | | traits::{ |
15 | | ProjectRepositoryTrait, QualityAttributeRepositoryTrait, |
16 | | QualityComponentLinkRepositoryTrait, |
17 | | }, |
18 | | }, |
19 | | models::{ |
20 | | organisation::OrganisationId, |
21 | | profile::UserId, |
22 | | project::ProjectId, |
23 | | quality_attribute::{ |
24 | | CreateQualityAttributeRequest, QualityAttribute, QualityAttributeId, |
25 | | UpdateQualityAttributeRequest, |
26 | | }, |
27 | | }, |
28 | | services::unified_component::UnifiedComponentService, |
29 | | }; |
30 | | |
31 | | const MAX_QUALITY_ATTRIBUTES: usize = 10; |
32 | | |
33 | | /// Service for quality attribute business logic |
34 | | #[derive(Clone)] |
35 | | pub struct QualityAttributeService { |
36 | | #[cfg(not(test))] |
37 | | unified: UnifiedComponentService, |
38 | | #[cfg(test)] |
39 | | unified: Option<UnifiedComponentService>, |
40 | | attribute_repo: Arc<dyn QualityAttributeRepositoryTrait>, |
41 | | link_repo: Arc<dyn QualityComponentLinkRepositoryTrait>, |
42 | | project_repo: Arc<dyn ProjectRepositoryTrait>, |
43 | | } |
44 | | |
45 | | impl QualityAttributeService { |
46 | | /// Create a new QualityAttributeService |
47 | 6 | pub fn new(database: TenetDatabase) -> Self { |
48 | 6 | Self { |
49 | 6 | #[cfg(not(test))] |
50 | 6 | unified: UnifiedComponentService::new(database.clone()), |
51 | 6 | #[cfg(test)] |
52 | 6 | unified: Some(0 UnifiedComponentService::new0 (database.clone())), |
53 | 6 | attribute_repo: Arc::new(QualityAttributeRepository::new(database.clone())), |
54 | 6 | link_repo: Arc::new(QualityComponentLinkRepository::new(database.clone())), |
55 | 6 | project_repo: Arc::new(ProjectRepository::new(database.clone())), |
56 | 6 | } |
57 | 6 | } |
58 | | |
59 | | #[cfg(test)] |
60 | 31 | pub fn new_with_repos( |
61 | 31 | attribute_repo: Arc<dyn QualityAttributeRepositoryTrait>, |
62 | 31 | link_repo: Arc<dyn QualityComponentLinkRepositoryTrait>, |
63 | 31 | project_repo: Arc<dyn ProjectRepositoryTrait>, |
64 | 31 | ) -> Self { |
65 | 31 | Self { |
66 | 31 | unified: None, |
67 | 31 | attribute_repo, |
68 | 31 | link_repo, |
69 | 31 | project_repo, |
70 | 31 | } |
71 | 31 | } |
72 | | |
73 | | /// Verify project exists |
74 | 25 | async fn verify_project_exists( |
75 | 25 | &self, |
76 | 25 | org_id: &OrganisationId, |
77 | 25 | project_id: &ProjectId, |
78 | 25 | ) -> Result<(), ApiError> { |
79 | 25 | if !self.project_repo.project_exists(org_id, project_id).await?0 { |
80 | 0 | return Err(ApiError::NotFound(format!( |
81 | 0 | "Project {} not found", |
82 | 0 | project_id |
83 | 0 | ))); |
84 | 25 | } |
85 | 25 | Ok(()) |
86 | 25 | } |
87 | | |
88 | 23 | fn validate_priority(priority: i32) -> Result<(), ApiError> { |
89 | 23 | if !(1..=10).contains(&priority) { |
90 | 1 | return Err(ApiError::InvalidRequest( |
91 | 1 | "Priority must be between 1 and 10".to_string(), |
92 | 1 | )); |
93 | 22 | } |
94 | 22 | Ok(()) |
95 | 23 | } |
96 | | |
97 | 69 | fn normalize_name(name: &str) -> String { |
98 | 69 | name.trim().to_lowercase() |
99 | 69 | } |
100 | | |
101 | 18 | fn normalize_optional_string(value: Option<String>) -> Option<String> { |
102 | 18 | value.and_then(|value| {2 |
103 | 2 | let trimmed = value.trim(); |
104 | 2 | if trimmed.is_empty() { |
105 | 0 | None |
106 | | } else { |
107 | 2 | Some(trimmed.to_string()) |
108 | | } |
109 | 2 | }) |
110 | 18 | } |
111 | | |
112 | 2 | fn normalize_optional_string_for_update(value: Option<String>) -> Option<String> { |
113 | 2 | value.map(|value| {1 |
114 | 1 | let trimmed = value.trim(); |
115 | 1 | if trimmed.is_empty() { |
116 | 1 | String::new() |
117 | | } else { |
118 | 0 | trimmed.to_string() |
119 | | } |
120 | 1 | }) |
121 | 2 | } |
122 | | |
123 | 19 | fn normalize_constraints(value: Option<Vec<String>>) -> Option<Vec<String>> { |
124 | 19 | value |
125 | 19 | .map(|constraints| {3 |
126 | 3 | constraints |
127 | 3 | .into_iter() |
128 | 4 | .map3 (|item| item.trim().to_string()) |
129 | 4 | .filter3 (|item| !item.is_empty()) |
130 | 3 | .collect::<Vec<String>>() |
131 | 3 | }) |
132 | 19 | .filter(|constraints| !constraints3 .is_empty3 ()) |
133 | 19 | } |
134 | | |
135 | | // ========================================================================= |
136 | | // Quality Attribute Operations |
137 | | // ========================================================================= |
138 | | |
139 | | /// Create a new quality attribute |
140 | 20 | pub async fn create_quality_attribute( |
141 | 20 | &self, |
142 | 20 | org_id: &OrganisationId, |
143 | 20 | project_id: &ProjectId, |
144 | 20 | _user_id: &UserId, |
145 | 20 | request: &CreateQualityAttributeRequest, |
146 | 20 | ) -> Result<QualityAttribute, ApiError> { |
147 | 20 | self.verify_project_exists(org_id, project_id).await?0 ; |
148 | | |
149 | 20 | let name = request.name.trim(); |
150 | 20 | if name.is_empty() { |
151 | 0 | return Err(ApiError::InvalidRequest( |
152 | 0 | "Quality attribute name cannot be empty".to_string(), |
153 | 0 | )); |
154 | 20 | } |
155 | 20 | Self::validate_priority(request.priority)?0 ; |
156 | | |
157 | 20 | let existing = self.attribute_repo.list_by_project(project_id).await?0 ; |
158 | 20 | if existing.len() >= MAX_QUALITY_ATTRIBUTES { |
159 | 1 | return Err(ApiError::InvalidRequest(format!( |
160 | 1 | "Projects can have at most {} quality attributes", |
161 | 1 | MAX_QUALITY_ATTRIBUTES |
162 | 1 | ))); |
163 | 19 | } |
164 | | |
165 | 19 | let normalized_name = Self::normalize_name(name); |
166 | 19 | if existing |
167 | 19 | .iter() |
168 | 48 | .any19 (|attr| Self::normalize_name(&attr.name) == normalized_name) |
169 | | { |
170 | 1 | return Err(ApiError::InvalidRequest(format!( |
171 | 1 | "Quality attribute '{}' already exists", |
172 | 1 | name |
173 | 1 | ))); |
174 | 18 | } |
175 | 18 | let attribute = QualityAttribute::new( |
176 | 18 | org_id.clone(), |
177 | 18 | project_id.clone(), |
178 | 18 | name.to_string(), |
179 | 18 | request.priority, |
180 | 18 | Self::normalize_optional_string(request.description.clone()), |
181 | 18 | Self::normalize_constraints(request.constraints.clone()), |
182 | | ); |
183 | | |
184 | 18 | let request = CreateQualityAttributeRequest { |
185 | 18 | name: attribute.name.clone(), |
186 | 18 | priority: attribute.priority, |
187 | 18 | description: attribute.description.clone(), |
188 | 18 | constraints: attribute.constraints.clone(), |
189 | 18 | }; |
190 | | #[cfg(not(test))] |
191 | | { |
192 | 0 | return self |
193 | 0 | .unified |
194 | 0 | .create_quality_attribute(org_id, project_id, _user_id, &request) |
195 | 0 | .await; |
196 | | } |
197 | | #[cfg(test)] |
198 | | { |
199 | 18 | if let Some(unified0 ) = &self.unified { |
200 | 0 | return unified |
201 | 0 | .create_quality_attribute(org_id, project_id, _user_id, &request) |
202 | 0 | .await; |
203 | 18 | } |
204 | | |
205 | 18 | self.attribute_repo.create(&attribute).await?0 ; |
206 | | |
207 | 18 | Ok(attribute) |
208 | | } |
209 | 20 | } |
210 | | |
211 | | /// Get a quality attribute by ID |
212 | 2 | pub async fn get_quality_attribute( |
213 | 2 | &self, |
214 | 2 | org_id: &OrganisationId, |
215 | 2 | project_id: &ProjectId, |
216 | 2 | quality_attribute_id: &QualityAttributeId, |
217 | 2 | _user_id: &UserId, |
218 | 2 | ) -> Result<QualityAttribute, ApiError> { |
219 | | #[cfg(not(test))] |
220 | | { |
221 | 0 | return self |
222 | 0 | .unified |
223 | 0 | .get_quality_attribute(org_id, project_id, quality_attribute_id) |
224 | 0 | .await; |
225 | | } |
226 | | #[cfg(test)] |
227 | | { |
228 | 2 | if let Some(unified0 ) = &self.unified { |
229 | 0 | return unified |
230 | 0 | .get_quality_attribute(org_id, project_id, quality_attribute_id) |
231 | 0 | .await; |
232 | 2 | } |
233 | | |
234 | 2 | self.attribute_repo |
235 | 2 | .get(org_id, project_id, quality_attribute_id) |
236 | 2 | .await?0 |
237 | 2 | .ok_or_else(|| {1 |
238 | 1 | ApiError::NotFound(format!( |
239 | 1 | "Quality attribute {} not found", |
240 | 1 | quality_attribute_id |
241 | 1 | )) |
242 | 1 | }) |
243 | | } |
244 | 2 | } |
245 | | |
246 | | /// List all quality attributes for a project |
247 | 2 | pub async fn list_quality_attributes( |
248 | 2 | &self, |
249 | 2 | org_id: &OrganisationId, |
250 | 2 | project_id: &ProjectId, |
251 | 2 | _user_id: &UserId, |
252 | 2 | ) -> Result<Vec<QualityAttribute>, ApiError> { |
253 | | #[cfg(not(test))] |
254 | | { |
255 | 0 | return self |
256 | 0 | .unified |
257 | 0 | .list_quality_attributes(org_id, project_id) |
258 | 0 | .await; |
259 | | } |
260 | | #[cfg(test)] |
261 | | { |
262 | 2 | if let Some(unified0 ) = &self.unified { |
263 | 0 | return unified.list_quality_attributes(org_id, project_id).await; |
264 | 2 | } |
265 | 2 | self.verify_project_exists(org_id, project_id).await?0 ; |
266 | | |
267 | 2 | self.attribute_repo.list_by_project(project_id).await |
268 | | } |
269 | 2 | } |
270 | | |
271 | | /// Update a quality attribute |
272 | 3 | pub async fn update_quality_attribute( |
273 | 3 | &self, |
274 | 3 | org_id: &OrganisationId, |
275 | 3 | project_id: &ProjectId, |
276 | 3 | quality_attribute_id: &QualityAttributeId, |
277 | 3 | _user_id: &UserId, |
278 | 3 | request: UpdateQualityAttributeRequest, |
279 | 3 | ) -> Result<QualityAttribute, ApiError> { |
280 | 3 | self.verify_project_exists(org_id, project_id).await?0 ; |
281 | | |
282 | 3 | let existing = self |
283 | 3 | .attribute_repo |
284 | 3 | .get(org_id, project_id, quality_attribute_id) |
285 | 3 | .await?0 |
286 | 3 | .ok_or_else(|| {0 |
287 | 0 | ApiError::NotFound(format!( |
288 | 0 | "Quality attribute {} not found", |
289 | 0 | quality_attribute_id |
290 | 0 | )) |
291 | 0 | })?; |
292 | | |
293 | 3 | if let Some(priority) = request.priority { |
294 | 3 | Self::validate_priority(priority)?1 ; |
295 | 0 | } |
296 | | |
297 | 2 | let mut other_attributes = self.attribute_repo.list_by_project(project_id).await?0 ; |
298 | 4 | other_attributes2 .retain2 (|attr| attr.id != existing.id); |
299 | | |
300 | 2 | if let Some(ref name1 ) = request.name { |
301 | 1 | let trimmed = name.trim(); |
302 | 1 | if trimmed.is_empty() { |
303 | 0 | return Err(ApiError::InvalidRequest( |
304 | 0 | "Quality attribute name cannot be empty".to_string(), |
305 | 0 | )); |
306 | 1 | } |
307 | 1 | let normalized = Self::normalize_name(trimmed); |
308 | 1 | if other_attributes |
309 | 1 | .iter() |
310 | 1 | .any(|attr| Self::normalize_name(&attr.name) == normalized) |
311 | | { |
312 | 0 | return Err(ApiError::InvalidRequest(format!( |
313 | 0 | "Quality attribute '{}' already exists", |
314 | 0 | trimmed |
315 | 0 | ))); |
316 | 1 | } |
317 | 1 | } |
318 | | |
319 | 2 | let normalized_constraints = request |
320 | 2 | .constraints |
321 | 2 | .map(|constraints| Self::normalize_constraints1 (Some(constraints)1 ).unwrap_or_default1 ()); |
322 | | |
323 | 2 | let update_request = UpdateQualityAttributeRequest { |
324 | 2 | name: request.name.map(|name| name.trim()1 .to_string1 ()), |
325 | 2 | priority: request.priority, |
326 | 2 | description: Self::normalize_optional_string_for_update(request.description), |
327 | 2 | constraints: normalized_constraints, |
328 | | }; |
329 | | |
330 | | #[cfg(not(test))] |
331 | | { |
332 | 0 | return self |
333 | 0 | .unified |
334 | 0 | .update_quality_attribute( |
335 | 0 | org_id, |
336 | 0 | project_id, |
337 | 0 | _user_id, |
338 | 0 | quality_attribute_id, |
339 | 0 | update_request, |
340 | 0 | ) |
341 | 0 | .await; |
342 | | } |
343 | | #[cfg(test)] |
344 | | { |
345 | 2 | if let Some(unified0 ) = &self.unified { |
346 | 0 | return unified |
347 | 0 | .update_quality_attribute( |
348 | 0 | org_id, |
349 | 0 | project_id, |
350 | 0 | _user_id, |
351 | 0 | quality_attribute_id, |
352 | 0 | update_request, |
353 | 0 | ) |
354 | 0 | .await; |
355 | 2 | } |
356 | | |
357 | 2 | self.attribute_repo |
358 | 2 | .update(org_id, project_id, quality_attribute_id, update_request) |
359 | 2 | .await?0 |
360 | 2 | .ok_or_else(|| {0 |
361 | 0 | ApiError::NotFound(format!( |
362 | 0 | "Quality attribute {} not found", |
363 | 0 | quality_attribute_id |
364 | 0 | )) |
365 | 0 | }) |
366 | | } |
367 | 3 | } |
368 | | |
369 | | /// Delete a quality attribute (and its links) |
370 | 1 | pub async fn delete_quality_attribute( |
371 | 1 | &self, |
372 | 1 | org_id: &OrganisationId, |
373 | 1 | project_id: &ProjectId, |
374 | 1 | quality_attribute_id: &QualityAttributeId, |
375 | 1 | _user_id: &UserId, |
376 | 1 | ) -> Result<(), ApiError> { |
377 | 1 | self.attribute_repo |
378 | 1 | .get(org_id, project_id, quality_attribute_id) |
379 | 1 | .await?0 |
380 | 1 | .ok_or_else(|| {0 |
381 | 0 | ApiError::NotFound(format!( |
382 | 0 | "Quality attribute {} not found", |
383 | 0 | quality_attribute_id |
384 | 0 | )) |
385 | 0 | })?; |
386 | | |
387 | 1 | self.link_repo |
388 | 1 | .delete_all_for_quality(quality_attribute_id) |
389 | 1 | .await?0 ; |
390 | | #[cfg(not(test))] |
391 | | { |
392 | 0 | return self |
393 | 0 | .unified |
394 | 0 | .delete_quality_attribute(org_id, project_id, _user_id, quality_attribute_id) |
395 | 0 | .await; |
396 | | } |
397 | | #[cfg(test)] |
398 | | { |
399 | 1 | if let Some(unified0 ) = &self.unified { |
400 | 0 | return unified |
401 | 0 | .delete_quality_attribute(org_id, project_id, _user_id, quality_attribute_id) |
402 | 0 | .await; |
403 | 1 | } |
404 | | |
405 | 1 | self.attribute_repo |
406 | 1 | .delete(org_id, project_id, quality_attribute_id) |
407 | 1 | .await?0 ; |
408 | | |
409 | 1 | Ok(()) |
410 | | } |
411 | 1 | } |
412 | | } |
413 | | |
414 | | // ============================================================================= |
415 | | // Tests |
416 | | // ============================================================================= |
417 | | |
418 | | #[cfg(test)] |
419 | | mod tests { |
420 | | use super::*; |
421 | | use crate::db::in_memory::{ |
422 | | InMemoryMemberRepository, InMemoryOrganisationRepository, InMemoryProjectRepository, |
423 | | InMemoryQualityAttributeRepository, InMemoryQualityComponentLinkRepository, |
424 | | }; |
425 | | use crate::db::traits::QualityComponentLinkRepositoryTrait; |
426 | | use crate::models::{ |
427 | | component::ComponentId, organisation::Organisation, project::Project, |
428 | | quality_component_link::QualityComponentLink, |
429 | | quality_component_link::QualityLinkComponentType, role::Role, |
430 | | }; |
431 | | use crate::test_support::{ |
432 | | fixtures::{add_member, seed_org_project_with_admin}, |
433 | | must_ok, |
434 | | }; |
435 | | use std::sync::Arc; |
436 | | |
437 | 5 | fn setup_service( |
438 | 5 | _org_repo: InMemoryOrganisationRepository, |
439 | 5 | _member_repo: InMemoryMemberRepository, |
440 | 5 | project_repo: InMemoryProjectRepository, |
441 | 5 | attribute_repo: InMemoryQualityAttributeRepository, |
442 | 5 | link_repo: InMemoryQualityComponentLinkRepository, |
443 | 5 | ) -> QualityAttributeService { |
444 | 5 | QualityAttributeService::new_with_repos( |
445 | 5 | Arc::new(attribute_repo), |
446 | 5 | Arc::new(link_repo), |
447 | 5 | Arc::new(project_repo), |
448 | | ) |
449 | 5 | } |
450 | | |
451 | 5 | fn seed_org_and_project( |
452 | 5 | org_repo: &InMemoryOrganisationRepository, |
453 | 5 | member_repo: &InMemoryMemberRepository, |
454 | 5 | project_repo: &InMemoryProjectRepository, |
455 | 5 | ) -> (Organisation, Project, UserId) { |
456 | 5 | let seeded = seed_org_project_with_admin(org_repo, member_repo, project_repo); |
457 | 5 | (seeded.org, seeded.project, seeded.admin_user) |
458 | 5 | } |
459 | | |
460 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
461 | | #[allure_rs::allure_test] |
462 | | #[tokio::test] |
463 | | async fn test_create_and_list_quality_attributes() { |
464 | | let org_repo = InMemoryOrganisationRepository::default(); |
465 | | let member_repo = InMemoryMemberRepository::default(); |
466 | | let project_repo = InMemoryProjectRepository::default(); |
467 | | let attribute_repo = InMemoryQualityAttributeRepository::default(); |
468 | | let link_repo = InMemoryQualityComponentLinkRepository::default(); |
469 | | |
470 | | let service = setup_service( |
471 | | org_repo.clone(), |
472 | | member_repo.clone(), |
473 | | project_repo.clone(), |
474 | | attribute_repo, |
475 | | link_repo, |
476 | | ); |
477 | | |
478 | | let (org, project, user_id) = seed_org_and_project(&org_repo, &member_repo, &project_repo); |
479 | | |
480 | | let request = CreateQualityAttributeRequest { |
481 | | name: "Security".to_string(), |
482 | | priority: 1, |
483 | | description: Some("Protect data".to_string()), |
484 | | constraints: Some(vec!["No secrets in logs".to_string()]), |
485 | | }; |
486 | | |
487 | | let created = must_ok( |
488 | | service |
489 | | .create_quality_attribute(&org.id, &project.id, &user_id, &request) |
490 | | .await, |
491 | | ); |
492 | | assert_eq!(created.name, "Security"); |
493 | | |
494 | | let list = must_ok( |
495 | | service |
496 | | .list_quality_attributes(&org.id, &project.id, &user_id) |
497 | | .await, |
498 | | ); |
499 | | assert_eq!(list.len(), 1); |
500 | | assert_eq!(list[0].id, created.id); |
501 | | } |
502 | | |
503 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
504 | | #[allure_rs::allure_test] |
505 | | #[tokio::test] |
506 | | async fn test_quality_attribute_unique_name_allows_duplicate_priority() { |
507 | | let org_repo = InMemoryOrganisationRepository::default(); |
508 | | let member_repo = InMemoryMemberRepository::default(); |
509 | | let project_repo = InMemoryProjectRepository::default(); |
510 | | let attribute_repo = InMemoryQualityAttributeRepository::default(); |
511 | | let link_repo = InMemoryQualityComponentLinkRepository::default(); |
512 | | |
513 | | let service = setup_service( |
514 | | org_repo.clone(), |
515 | | member_repo.clone(), |
516 | | project_repo.clone(), |
517 | | attribute_repo, |
518 | | link_repo, |
519 | | ); |
520 | | |
521 | | let (org, project, user_id) = seed_org_and_project(&org_repo, &member_repo, &project_repo); |
522 | | |
523 | | let base_request = CreateQualityAttributeRequest { |
524 | | name: "Security".to_string(), |
525 | | priority: 1, |
526 | | description: None, |
527 | | constraints: None, |
528 | | }; |
529 | | |
530 | | must_ok( |
531 | | service |
532 | | .create_quality_attribute(&org.id, &project.id, &user_id, &base_request) |
533 | | .await, |
534 | | ); |
535 | | |
536 | | let duplicate_name = CreateQualityAttributeRequest { |
537 | | name: "security".to_string(), |
538 | | priority: 2, |
539 | | description: None, |
540 | | constraints: None, |
541 | | }; |
542 | | let result = service |
543 | | .create_quality_attribute(&org.id, &project.id, &user_id, &duplicate_name) |
544 | | .await; |
545 | | assert!(matches!(result, Err(ApiError::InvalidRequest(_)))); |
546 | | |
547 | | let duplicate_priority = CreateQualityAttributeRequest { |
548 | | name: "Performance".to_string(), |
549 | | priority: 1, |
550 | | description: None, |
551 | | constraints: None, |
552 | | }; |
553 | | let result = service |
554 | | .create_quality_attribute(&org.id, &project.id, &user_id, &duplicate_priority) |
555 | | .await; |
556 | | assert!(result.is_ok(), "duplicate priorities should be allowed"); |
557 | | } |
558 | | |
559 | | #[allure_rs::allure_parent_suite("tenet-aws")] |
560 | | #[allure_rs::allure_test] |
561 | | #[tokio::test] |
562 | | async fn test_quality_attribute_limit() { |
563 | | let org_repo = InMemoryOrganisationRepository::default(); |
564 | | let member_repo = InMemoryMemberRepository::default(); |
565 | | let project_repo = InMemoryProjectRepository::default(); |
566 | | let attribute_repo = InMemoryQualityAttributeRepository::default(); |
567 | | let link_repo = InMemoryQualityComponentLinkRepository::default(); |
568 | | |
569 | | let service = setup_service( |
570 | | org_repo.clone(), |
571 | | member_repo.clone(), |
572 | | project_repo.clone(), |
573 | | attribute_repo, |
574 | | link_repo, |
575 | | ); |
576 | | |
577 | | let (org, project, user_id) = seed_org_and_project(&org_repo, &member_repo, &project_repo); |
578 | | |
579 | | for idx in 1..=10 { |
580 | | let request = CreateQualityAttributeRequest { |
581 | | name: format!("Quality {}", idx), |
582 | | priority: idx, |
583 | | description: None, |
584 | | constraints: None, |
585 | | }; |
586 | | must_ok( |
587 | | service |
588 | | .create_quality_attribute(&org.id, &project.id, &user_id, &request) |
589 | | .await, |
590 | | ); |
591 | | } |
592 | | |
593 | | let extra = CreateQualityAttributeRequest { |
594 | | name: "Extra".to_string(), |
595 | | priority: 10, |
596 | | description: None, |
597 | | constraints: None, |
598 | | }; |
599 | | let result = service |
600 | | .create_quality_attribute(&org.id, &project.id, &user_id, &extra) |
601 | | .await; |
602 | | assert!(matches!(result, Err(ApiError::InvalidRequest(_)))); |
603 | | } |
604 | | |
605 | | #[allure_rs::allure_test] |
606 | | #[tokio::test] |
607 | 1 | async fn test_get_update_and_validation_paths() { |
608 | 1 | let org_repo = InMemoryOrganisationRepository::default(); |
609 | 1 | let member_repo = InMemoryMemberRepository::default(); |
610 | 1 | let project_repo = InMemoryProjectRepository::default(); |
611 | 1 | let attribute_repo = InMemoryQualityAttributeRepository::default(); |
612 | 1 | let link_repo = InMemoryQualityComponentLinkRepository::default(); |
613 | | |
614 | 1 | let service = setup_service( |
615 | 1 | org_repo.clone(), |
616 | 1 | member_repo.clone(), |
617 | 1 | project_repo.clone(), |
618 | 1 | attribute_repo, |
619 | 1 | link_repo, |
620 | | ); |
621 | | |
622 | 1 | let (org, project, user_id) = seed_org_and_project(&org_repo, &member_repo, &project_repo); |
623 | 1 | let first = must_ok( |
624 | 1 | service |
625 | 1 | .create_quality_attribute( |
626 | 1 | &org.id, |
627 | 1 | &project.id, |
628 | 1 | &user_id, |
629 | 1 | &CreateQualityAttributeRequest { |
630 | 1 | name: "Security".to_string(), |
631 | 1 | priority: 1, |
632 | 1 | description: Some("Protect data".to_string()), |
633 | 1 | constraints: Some(vec!["encrypted".to_string()]), |
634 | 1 | }, |
635 | 1 | ) |
636 | 1 | .await, |
637 | | ); |
638 | 1 | let second = must_ok( |
639 | 1 | service |
640 | 1 | .create_quality_attribute( |
641 | 1 | &org.id, |
642 | 1 | &project.id, |
643 | 1 | &user_id, |
644 | 1 | &CreateQualityAttributeRequest { |
645 | 1 | name: "Performance".to_string(), |
646 | 1 | priority: 2, |
647 | 1 | description: None, |
648 | 1 | constraints: None, |
649 | 1 | }, |
650 | 1 | ) |
651 | 1 | .await, |
652 | | ); |
653 | | |
654 | 1 | let fetched = must_ok( |
655 | 1 | service |
656 | 1 | .get_quality_attribute(&org.id, &project.id, &first.id, &user_id) |
657 | 1 | .await, |
658 | | ); |
659 | 1 | assert_eq!(fetched.id, first.id); |
660 | | |
661 | 1 | let updated = must_ok( |
662 | 1 | service |
663 | 1 | .update_quality_attribute( |
664 | 1 | &org.id, |
665 | 1 | &project.id, |
666 | 1 | &first.id, |
667 | 1 | &user_id, |
668 | 1 | UpdateQualityAttributeRequest { |
669 | 1 | name: Some("Security Updated".to_string()), |
670 | 1 | priority: Some(3), |
671 | 1 | description: Some("".to_string()), |
672 | 1 | constraints: Some(vec![" ".to_string(), "auditable".to_string()]), |
673 | 1 | }, |
674 | 1 | ) |
675 | 1 | .await, |
676 | | ); |
677 | 1 | assert_eq!(updated.name, "Security Updated"); |
678 | 1 | assert_eq!(updated.priority, 3); |
679 | 1 | assert_eq!(updated.description, None); |
680 | 1 | assert_eq!(updated.constraints, Some(vec!["auditable".to_string()])); |
681 | | |
682 | 1 | let duplicate_priority = service |
683 | 1 | .update_quality_attribute( |
684 | 1 | &org.id, |
685 | 1 | &project.id, |
686 | 1 | &first.id, |
687 | 1 | &user_id, |
688 | 1 | UpdateQualityAttributeRequest { |
689 | 1 | name: None, |
690 | 1 | priority: Some(second.priority), |
691 | 1 | description: None, |
692 | 1 | constraints: None, |
693 | 1 | }, |
694 | 1 | ) |
695 | 1 | .await; |
696 | 1 | assert!( |
697 | 1 | duplicate_priority.is_ok(), |
698 | | "updating to an existing priority should be allowed" |
699 | | ); |
700 | | |
701 | 1 | let invalid_priority = service |
702 | 1 | .update_quality_attribute( |
703 | 1 | &org.id, |
704 | 1 | &project.id, |
705 | 1 | &first.id, |
706 | 1 | &user_id, |
707 | 1 | UpdateQualityAttributeRequest { |
708 | 1 | name: None, |
709 | 1 | priority: Some(11), |
710 | 1 | description: None, |
711 | 1 | constraints: None, |
712 | 1 | }, |
713 | 1 | ) |
714 | 1 | .await; |
715 | 1 | assert!(matches!0 (invalid_priority, Err(ApiError::InvalidRequest(_)))); |
716 | | } |
717 | | |
718 | | #[allure_rs::allure_test] |
719 | | #[tokio::test] |
720 | 1 | async fn test_delete_quality_attribute_auth_is_enforced_at_boundary_and_cleans_links() { |
721 | 1 | let org_repo = InMemoryOrganisationRepository::default(); |
722 | 1 | let member_repo = InMemoryMemberRepository::default(); |
723 | 1 | let project_repo = InMemoryProjectRepository::default(); |
724 | 1 | let attribute_repo = InMemoryQualityAttributeRepository::default(); |
725 | 1 | let link_repo = InMemoryQualityComponentLinkRepository::default(); |
726 | | |
727 | 1 | let service = setup_service( |
728 | 1 | org_repo.clone(), |
729 | 1 | member_repo.clone(), |
730 | 1 | project_repo.clone(), |
731 | 1 | attribute_repo, |
732 | 1 | link_repo.clone(), |
733 | | ); |
734 | | |
735 | 1 | let (org, project, admin_user) = |
736 | 1 | seed_org_and_project(&org_repo, &member_repo, &project_repo); |
737 | 1 | let member_user = add_member( |
738 | 1 | &member_repo, |
739 | 1 | &org.id, |
740 | 1 | "550e8400-e29b-41d4-a716-446655440001", |
741 | 1 | Role::User, |
742 | 1 | Some(admin_user.clone()), |
743 | | ); |
744 | | |
745 | 1 | let attribute = must_ok( |
746 | 1 | service |
747 | 1 | .create_quality_attribute( |
748 | 1 | &org.id, |
749 | 1 | &project.id, |
750 | 1 | &admin_user, |
751 | 1 | &CreateQualityAttributeRequest { |
752 | 1 | name: "Maintainability".to_string(), |
753 | 1 | priority: 4, |
754 | 1 | description: None, |
755 | 1 | constraints: None, |
756 | 1 | }, |
757 | 1 | ) |
758 | 1 | .await, |
759 | | ); |
760 | | |
761 | 1 | let link = QualityComponentLink::new( |
762 | 1 | org.id.clone(), |
763 | 1 | project.id.clone(), |
764 | 1 | attribute.id.clone(), |
765 | 1 | ComponentId::new(), |
766 | 1 | QualityLinkComponentType::Container, |
767 | 1 | None, |
768 | 1 | Some("linked".to_string()), |
769 | | ); |
770 | 1 | must_ok(link_repo.create(&link).await); |
771 | 1 | assert_eq!( |
772 | 1 | must_ok(link_repo.list_by_quality(&attribute.id).await).len(), |
773 | | 1 |
774 | | ); |
775 | | |
776 | 1 | let deleted = service |
777 | 1 | .delete_quality_attribute(&org.id, &project.id, &attribute.id, &member_user) |
778 | 1 | .await; |
779 | 1 | assert!(deleted.is_ok()); |
780 | | |
781 | 1 | assert!( |
782 | 1 | service |
783 | 1 | .get_quality_attribute(&org.id, &project.id, &attribute.id, &member_user) |
784 | 1 | .await |
785 | 1 | .is_err() |
786 | | ); |
787 | 1 | assert!(must_ok(link_repo.list_by_quality(&attribute.id).await).is_empty()); |
788 | | } |
789 | | } |