1use std::fmt;
4
5use serde_json::Value;
6
7use crate::pid_requirements::{CodeValue, EntityRequirement, FieldRequirement, PidRequirements};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Severity {
12 Error,
14 Warning,
16}
17
18#[derive(Debug, Clone)]
20pub enum PidValidationError {
21 MissingEntity {
23 entity: String,
24 ahb_status: String,
25 severity: Severity,
26 },
27 MissingField {
29 entity: String,
30 field: String,
31 ahb_status: String,
32 rust_type: Option<String>,
33 valid_values: Vec<(String, String)>,
34 severity: Severity,
35 },
36 InvalidCode {
38 entity: String,
39 field: String,
40 value: String,
41 valid_values: Vec<(String, String)>,
42 },
43}
44
45impl PidValidationError {
46 pub fn severity(&self) -> &Severity {
47 match self {
48 Self::MissingEntity { severity, .. } => severity,
49 Self::MissingField { severity, .. } => severity,
50 Self::InvalidCode { .. } => &Severity::Error,
51 }
52 }
53
54 pub fn is_error(&self) -> bool {
55 matches!(self.severity(), Severity::Error)
56 }
57}
58
59impl fmt::Display for PidValidationError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 PidValidationError::MissingEntity {
63 entity,
64 ahb_status,
65 severity,
66 } => {
67 let label = severity_label(severity);
68 write!(
69 f,
70 "{label}: missing entity '{entity}' (required: {ahb_status})"
71 )
72 }
73 PidValidationError::MissingField {
74 entity,
75 field,
76 ahb_status,
77 rust_type,
78 valid_values,
79 severity,
80 } => {
81 let label = severity_label(severity);
82 write!(
83 f,
84 "{label}: missing {entity}.{field} (required: {ahb_status})"
85 )?;
86 if let Some(rt) = rust_type {
87 write!(f, "\n → type: {rt}")?;
88 }
89 if !valid_values.is_empty() {
90 let codes: Vec<String> = valid_values
91 .iter()
92 .map(|(code, meaning)| {
93 if meaning.is_empty() {
94 code.clone()
95 } else {
96 format!("{code} ({meaning})")
97 }
98 })
99 .collect();
100 write!(f, "\n → valid: {}", codes.join(", "))?;
101 }
102 Ok(())
103 }
104 PidValidationError::InvalidCode {
105 entity,
106 field,
107 value,
108 valid_values,
109 } => {
110 write!(f, "INVALID: {entity}.{field} = \"{value}\"")?;
111 if !valid_values.is_empty() {
112 let codes: Vec<String> = valid_values.iter().map(|(c, _)| c.clone()).collect();
113 write!(f, "\n → valid: {}", codes.join(", "))?;
114 }
115 Ok(())
116 }
117 }
118 }
119}
120
121fn severity_label(severity: &Severity) -> &'static str {
122 match severity {
123 Severity::Error => "ERROR",
124 Severity::Warning => "WARNING",
125 }
126}
127
128pub struct ValidationReport(pub Vec<PidValidationError>);
130
131impl ValidationReport {
132 pub fn has_errors(&self) -> bool {
134 self.0.iter().any(|e| e.is_error())
135 }
136
137 pub fn errors(&self) -> Vec<&PidValidationError> {
139 self.0.iter().filter(|e| e.is_error()).collect()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.0.is_empty()
145 }
146
147 pub fn len(&self) -> usize {
149 self.0.len()
150 }
151}
152
153impl fmt::Display for ValidationReport {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 for (i, err) in self.0.iter().enumerate() {
156 if i > 0 {
157 writeln!(f)?;
158 }
159 write!(f, "{err}")?;
160 }
161 Ok(())
162 }
163}
164
165pub fn validate_pid_json(json: &Value, requirements: &PidRequirements) -> Vec<PidValidationError> {
174 let mut errors = Vec::new();
175
176 for entity_req in &requirements.entities {
177 let key = to_camel_case(&entity_req.entity);
178
179 match json.get(&key) {
180 None => {
181 if is_unconditionally_required(&entity_req.ahb_status) {
182 errors.push(PidValidationError::MissingEntity {
183 entity: entity_req.entity.clone(),
184 ahb_status: entity_req.ahb_status.clone(),
185 severity: Severity::Error,
186 });
187 }
188 }
189 Some(val) => {
190 if entity_req.is_array {
191 if let Some(arr) = val.as_array() {
192 for element in arr {
193 validate_entity_fields(element, entity_req, &mut errors);
194 }
195 }
196 } else {
197 validate_entity_fields(val, entity_req, &mut errors);
198 }
199 }
200 }
201 }
202
203 errors
204}
205
206fn get_nested<'a>(json: &'a Value, path: &str) -> Option<&'a Value> {
209 let mut current = json;
210 for part in path.split('.') {
211 current = current.get(part).or_else(|| {
212 if part.contains('_') {
213 current.get(snake_to_camel_case(part))
214 } else {
215 None
216 }
217 })?;
218 }
219 Some(current)
220}
221
222fn validate_entity_fields(
224 entity_json: &Value,
225 entity_req: &EntityRequirement,
226 errors: &mut Vec<PidValidationError>,
227) {
228 for field_req in &entity_req.fields {
229 let val = get_nested(entity_json, &field_req.bo4e_name);
232
233 match val {
234 None => {
235 if is_unconditionally_required(&field_req.ahb_status) {
236 errors.push(PidValidationError::MissingField {
237 entity: entity_req.entity.clone(),
238 field: field_req.bo4e_name.clone(),
239 ahb_status: field_req.ahb_status.clone(),
240 rust_type: field_req.enum_name.clone(),
241 valid_values: code_values_to_tuples(&field_req.valid_codes),
242 severity: Severity::Error,
243 });
244 }
245 }
246 Some(val) => {
247 if !field_req.valid_codes.is_empty() {
248 validate_code_value(val, entity_req, field_req, errors);
249 }
250 }
251 }
252 }
253}
254
255fn validate_code_value(
257 val: &Value,
258 entity_req: &EntityRequirement,
259 field_req: &FieldRequirement,
260 errors: &mut Vec<PidValidationError>,
261) {
262 let value_str = match val.as_str() {
263 Some(s) => s,
264 None => return, };
266
267 let is_valid = field_req.valid_codes.iter().any(|cv| cv.code == value_str);
268 if !is_valid {
269 errors.push(PidValidationError::InvalidCode {
270 entity: entity_req.entity.clone(),
271 field: field_req.bo4e_name.clone(),
272 value: value_str.to_string(),
273 valid_values: code_values_to_tuples(&field_req.valid_codes),
274 });
275 }
276}
277
278fn code_values_to_tuples(codes: &[CodeValue]) -> Vec<(String, String)> {
280 codes
281 .iter()
282 .map(|cv| (cv.code.clone(), cv.meaning.clone()))
283 .collect()
284}
285
286fn to_camel_case(s: &str) -> String {
292 if s.is_empty() {
293 return String::new();
294 }
295 let mut chars = s.chars();
296 let first = chars.next().unwrap();
297 let mut result = first.to_lowercase().to_string();
298 result.extend(chars);
299 result
300}
301
302fn snake_to_camel_case(s: &str) -> String {
313 let mut result = String::with_capacity(s.len());
314 let mut capitalize_next = false;
315 for ch in s.chars() {
316 if ch == '_' {
317 capitalize_next = true;
318 } else if capitalize_next {
319 result.extend(ch.to_uppercase());
320 capitalize_next = false;
321 } else {
322 result.push(ch);
323 }
324 }
325 result
326}
327
328fn is_unconditionally_required(ahb_status: &str) -> bool {
330 matches!(ahb_status, "X" | "Muss" | "Soll")
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::pid_requirements::{
337 CodeValue, EntityRequirement, FieldRequirement, PidRequirements,
338 };
339 use serde_json::json;
340
341 fn sample_requirements() -> PidRequirements {
342 PidRequirements {
343 pid: "55001".to_string(),
344 beschreibung: "Anmeldung verb. MaLo".to_string(),
345 entities: vec![
346 EntityRequirement {
347 entity: "Prozessdaten".to_string(),
348 bo4e_type: "Prozessdaten".to_string(),
349 companion_type: None,
350 ahb_status: "Muss".to_string(),
351 is_array: false,
352 map_key: None,
353 fields: vec![
354 FieldRequirement {
355 bo4e_name: "vorgangId".to_string(),
356 ahb_status: "X".to_string(),
357 is_companion: false,
358 field_type: "data".to_string(),
359 format: None,
360 enum_name: None,
361 valid_codes: vec![],
362 child_group: None,
363 },
364 FieldRequirement {
365 bo4e_name: "transaktionsgrund".to_string(),
366 ahb_status: "X".to_string(),
367 is_companion: false,
368 field_type: "code".to_string(),
369 format: None,
370 enum_name: Some("Transaktionsgrund".to_string()),
371 valid_codes: vec![
372 CodeValue {
373 code: "E01".to_string(),
374 meaning: "Ein-/Auszug (Einzug)".to_string(),
375 enum_name: None,
376 },
377 CodeValue {
378 code: "E03".to_string(),
379 meaning: "Wechsel".to_string(),
380 enum_name: None,
381 },
382 ],
383 child_group: None,
384 },
385 ],
386 },
387 EntityRequirement {
388 entity: "Marktlokation".to_string(),
389 bo4e_type: "Marktlokation".to_string(),
390 companion_type: Some("MarktlokationEdifact".to_string()),
391 ahb_status: "Muss".to_string(),
392 is_array: false,
393 map_key: None,
394 fields: vec![
395 FieldRequirement {
396 bo4e_name: "marktlokationsId".to_string(),
397 ahb_status: "X".to_string(),
398 is_companion: false,
399 field_type: "data".to_string(),
400 format: None,
401 enum_name: None,
402 valid_codes: vec![],
403 child_group: None,
404 },
405 FieldRequirement {
406 bo4e_name: "haushaltskunde".to_string(),
407 ahb_status: "X".to_string(),
408 is_companion: false,
409 field_type: "code".to_string(),
410 format: None,
411 enum_name: Some("Haushaltskunde".to_string()),
412 valid_codes: vec![
413 CodeValue {
414 code: "Z15".to_string(),
415 meaning: "Ja".to_string(),
416 enum_name: None,
417 },
418 CodeValue {
419 code: "Z18".to_string(),
420 meaning: "Nein".to_string(),
421 enum_name: None,
422 },
423 ],
424 child_group: None,
425 },
426 ],
427 },
428 EntityRequirement {
429 entity: "Geschaeftspartner".to_string(),
430 bo4e_type: "Geschaeftspartner".to_string(),
431 companion_type: Some("GeschaeftspartnerEdifact".to_string()),
432 ahb_status: "Muss".to_string(),
433 is_array: true,
434 map_key: None,
435 fields: vec![FieldRequirement {
436 bo4e_name: "identifikation".to_string(),
437 ahb_status: "X".to_string(),
438 is_companion: false,
439 field_type: "data".to_string(),
440 format: None,
441 enum_name: None,
442 valid_codes: vec![],
443 child_group: None,
444 }],
445 },
446 ],
447 }
448 }
449
450 #[test]
451 fn test_validate_complete_json() {
452 let reqs = sample_requirements();
453 let json = json!({
454 "prozessdaten": {
455 "vorgangId": "ABC123",
456 "transaktionsgrund": "E01"
457 },
458 "marktlokation": {
459 "marktlokationsId": "51234567890",
460 "haushaltskunde": "Z15"
461 },
462 "geschaeftspartner": [
463 { "identifikation": "9900000000003" }
464 ]
465 });
466
467 let errors = validate_pid_json(&json, &reqs);
468 assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
469 }
470
471 #[test]
472 fn test_validate_missing_entity() {
473 let reqs = sample_requirements();
474 let json = json!({
475 "prozessdaten": {
476 "vorgangId": "ABC123",
477 "transaktionsgrund": "E01"
478 },
479 "geschaeftspartner": [
480 { "identifikation": "9900000000003" }
481 ]
482 });
483 let errors = validate_pid_json(&json, &reqs);
486 assert_eq!(errors.len(), 1);
487 match &errors[0] {
488 PidValidationError::MissingEntity {
489 entity,
490 ahb_status,
491 severity,
492 } => {
493 assert_eq!(entity, "Marktlokation");
494 assert_eq!(ahb_status, "Muss");
495 assert_eq!(severity, &Severity::Error);
496 }
497 other => panic!("Expected MissingEntity, got: {other:?}"),
498 }
499
500 let msg = errors[0].to_string();
502 assert!(msg.contains("ERROR"));
503 assert!(msg.contains("Marktlokation"));
504 assert!(msg.contains("Muss"));
505 }
506
507 #[test]
508 fn test_validate_missing_field() {
509 let reqs = sample_requirements();
510 let json = json!({
511 "prozessdaten": {
512 "transaktionsgrund": "E01"
513 },
515 "marktlokation": {
516 "marktlokationsId": "51234567890",
517 "haushaltskunde": "Z15"
518 },
519 "geschaeftspartner": [
520 { "identifikation": "9900000000003" }
521 ]
522 });
523
524 let errors = validate_pid_json(&json, &reqs);
525 assert_eq!(errors.len(), 1);
526 match &errors[0] {
527 PidValidationError::MissingField {
528 entity,
529 field,
530 ahb_status,
531 severity,
532 ..
533 } => {
534 assert_eq!(entity, "Prozessdaten");
535 assert_eq!(field, "vorgangId");
536 assert_eq!(ahb_status, "X");
537 assert_eq!(severity, &Severity::Error);
538 }
539 other => panic!("Expected MissingField, got: {other:?}"),
540 }
541
542 let msg = errors[0].to_string();
543 assert!(msg.contains("ERROR"));
544 assert!(msg.contains("Prozessdaten.vorgangId"));
545 }
546
547 #[test]
548 fn test_validate_invalid_code() {
549 let reqs = sample_requirements();
550 let json = json!({
551 "prozessdaten": {
552 "vorgangId": "ABC123",
553 "transaktionsgrund": "E01"
554 },
555 "marktlokation": {
556 "marktlokationsId": "51234567890",
557 "haushaltskunde": "Z99" },
559 "geschaeftspartner": [
560 { "identifikation": "9900000000003" }
561 ]
562 });
563
564 let errors = validate_pid_json(&json, &reqs);
565 assert_eq!(errors.len(), 1);
566 match &errors[0] {
567 PidValidationError::InvalidCode {
568 entity,
569 field,
570 value,
571 valid_values,
572 } => {
573 assert_eq!(entity, "Marktlokation");
574 assert_eq!(field, "haushaltskunde");
575 assert_eq!(value, "Z99");
576 assert_eq!(valid_values.len(), 2);
577 assert!(valid_values.iter().any(|(c, _)| c == "Z15"));
578 assert!(valid_values.iter().any(|(c, _)| c == "Z18"));
579 }
580 other => panic!("Expected InvalidCode, got: {other:?}"),
581 }
582
583 let msg = errors[0].to_string();
584 assert!(msg.contains("INVALID"));
585 assert!(msg.contains("Z99"));
586 assert!(msg.contains("Z15"));
587 }
588
589 #[test]
590 fn test_validate_array_entity() {
591 let reqs = sample_requirements();
592 let json = json!({
593 "prozessdaten": {
594 "vorgangId": "ABC123",
595 "transaktionsgrund": "E01"
596 },
597 "marktlokation": {
598 "marktlokationsId": "51234567890",
599 "haushaltskunde": "Z15"
600 },
601 "geschaeftspartner": [
602 { "identifikation": "9900000000003" },
603 { } ]
605 });
606
607 let errors = validate_pid_json(&json, &reqs);
608 assert_eq!(errors.len(), 1);
609 match &errors[0] {
610 PidValidationError::MissingField { entity, field, .. } => {
611 assert_eq!(entity, "Geschaeftspartner");
612 assert_eq!(field, "identifikation");
613 }
614 other => panic!("Expected MissingField, got: {other:?}"),
615 }
616 }
617
618 #[test]
619 fn test_to_camel_case() {
620 assert_eq!(to_camel_case("Prozessdaten"), "prozessdaten");
621 assert_eq!(
622 to_camel_case("RuhendeMarktlokation"),
623 "ruhendeMarktlokation"
624 );
625 assert_eq!(to_camel_case("Marktlokation"), "marktlokation");
626 assert_eq!(to_camel_case(""), "");
627 }
628
629 #[test]
630 fn test_snake_to_camel_case() {
631 assert_eq!(snake_to_camel_case("code_codepflege"), "codeCodepflege");
632 assert_eq!(snake_to_camel_case("vorgang_id"), "vorgangId");
633 assert_eq!(snake_to_camel_case("marktlokation"), "marktlokation");
634 assert_eq!(snake_to_camel_case(""), "");
635 assert_eq!(snake_to_camel_case("a_b_c"), "aBC");
636 }
637
638 #[test]
641 fn test_camel_case_fallback_for_snake_case_bo4e_name() {
642 let reqs = PidRequirements {
643 pid: "55077".to_string(),
644 beschreibung: "Test camelCase fallback".to_string(),
645 entities: vec![EntityRequirement {
646 entity: "Zuordnung".to_string(),
647 bo4e_type: "Zuordnung".to_string(),
648 companion_type: None,
649 ahb_status: "Muss".to_string(),
650 is_array: false,
651 map_key: None,
652 fields: vec![
653 FieldRequirement {
654 bo4e_name: "code_codepflege".to_string(),
656 ahb_status: "X".to_string(),
657 is_companion: false,
658 field_type: "data".to_string(),
659 format: None,
660 enum_name: None,
661 valid_codes: vec![],
662 child_group: None,
663 },
664 FieldRequirement {
665 bo4e_name: "codeliste".to_string(),
666 ahb_status: "X".to_string(),
667 is_companion: false,
668 field_type: "data".to_string(),
669 format: None,
670 enum_name: None,
671 valid_codes: vec![],
672 child_group: None,
673 },
674 ],
675 }],
676 };
677
678 let json_camel = json!({
681 "zuordnung": {
682 "codeCodepflege": "DE_BDEW",
683 "codeliste": "6"
684 }
685 });
686
687 let errors = validate_pid_json(&json_camel, &reqs);
688 assert!(
689 errors.is_empty(),
690 "Expected no errors when field is present under camelCase key, got: {errors:?}"
691 );
692
693 let json_snake = json!({
695 "zuordnung": {
696 "code_codepflege": "DE_BDEW",
697 "codeliste": "6"
698 }
699 });
700
701 let errors = validate_pid_json(&json_snake, &reqs);
702 assert!(
703 errors.is_empty(),
704 "Expected no errors when field is present under snake_case key, got: {errors:?}"
705 );
706
707 let json_missing = json!({
709 "zuordnung": {
710 "codeliste": "6"
711 }
712 });
713
714 let errors = validate_pid_json(&json_missing, &reqs);
715 assert_eq!(errors.len(), 1);
716 match &errors[0] {
717 PidValidationError::MissingField { field, .. } => {
718 assert_eq!(field, "code_codepflege");
719 }
720 other => panic!("Expected MissingField, got: {other:?}"),
721 }
722 }
723
724 #[test]
725 fn test_is_unconditionally_required() {
726 assert!(is_unconditionally_required("X"));
727 assert!(is_unconditionally_required("Muss"));
728 assert!(is_unconditionally_required("Soll"));
729 assert!(!is_unconditionally_required("Kann"));
730 assert!(!is_unconditionally_required("[1]"));
731 assert!(!is_unconditionally_required(""));
732 }
733
734 #[test]
735 fn test_validation_report_display() {
736 let errors = vec![
737 PidValidationError::MissingEntity {
738 entity: "Marktlokation".to_string(),
739 ahb_status: "Muss".to_string(),
740 severity: Severity::Error,
741 },
742 PidValidationError::MissingField {
743 entity: "Prozessdaten".to_string(),
744 field: "vorgangId".to_string(),
745 ahb_status: "X".to_string(),
746 rust_type: None,
747 valid_values: vec![],
748 severity: Severity::Error,
749 },
750 ];
751 let report = ValidationReport(errors);
752 assert!(report.has_errors());
753 assert_eq!(report.len(), 2);
754 assert!(!report.is_empty());
755
756 let display = report.to_string();
757 assert!(display.contains("missing entity 'Marktlokation'"));
758 assert!(display.contains("missing Prozessdaten.vorgangId"));
759 }
760
761 #[test]
762 fn test_missing_field_with_type_and_values_display() {
763 let err = PidValidationError::MissingField {
764 entity: "Marktlokation".to_string(),
765 field: "haushaltskunde".to_string(),
766 ahb_status: "Muss".to_string(),
767 rust_type: Some("Haushaltskunde".to_string()),
768 valid_values: vec![
769 ("Z15".to_string(), "Ja".to_string()),
770 ("Z18".to_string(), "Nein".to_string()),
771 ],
772 severity: Severity::Error,
773 };
774 let msg = err.to_string();
775 assert!(msg.contains("type: Haushaltskunde"));
776 assert!(msg.contains("valid: Z15 (Ja), Z18 (Nein)"));
777 }
778
779 #[test]
780 fn test_optional_fields_not_flagged() {
781 let reqs = PidRequirements {
782 pid: "99999".to_string(),
783 beschreibung: "Test".to_string(),
784 entities: vec![EntityRequirement {
785 entity: "Test".to_string(),
786 bo4e_type: "Test".to_string(),
787 companion_type: None,
788 ahb_status: "Kann".to_string(),
789 is_array: false,
790 map_key: None,
791 fields: vec![FieldRequirement {
792 bo4e_name: "optionalField".to_string(),
793 ahb_status: "Kann".to_string(),
794 is_companion: false,
795 field_type: "data".to_string(),
796 format: None,
797 enum_name: None,
798 valid_codes: vec![],
799 child_group: None,
800 }],
801 }],
802 };
803
804 let errors = validate_pid_json(&json!({}), &reqs);
806 assert!(errors.is_empty());
807
808 let errors = validate_pid_json(&json!({ "test": {} }), &reqs);
810 assert!(errors.is_empty());
811 }
812
813 #[test]
816 fn test_nested_dot_path_fields_not_falsely_missing() {
817 let reqs = PidRequirements {
818 pid: "55001".to_string(),
819 beschreibung: "Test nested paths".to_string(),
820 entities: vec![EntityRequirement {
821 entity: "ProduktpaketDaten".to_string(),
822 bo4e_type: "ProduktpaketDaten".to_string(),
823 companion_type: None,
824 ahb_status: "Muss".to_string(),
825 is_array: true,
826 map_key: None,
827 fields: vec![
828 FieldRequirement {
829 bo4e_name: "produktIdentifikation.funktion".to_string(),
830 ahb_status: "X".to_string(),
831 is_companion: false,
832 field_type: "code".to_string(),
833 format: None,
834 enum_name: Some("Produktidentifikation".to_string()),
835 valid_codes: vec![CodeValue {
836 code: "5".to_string(),
837 meaning: "Produktidentifikation".to_string(),
838 enum_name: None,
839 }],
840 child_group: None,
841 },
842 FieldRequirement {
843 bo4e_name: "produktMerkmal.code".to_string(),
844 ahb_status: "X".to_string(),
845 is_companion: false,
846 field_type: "code".to_string(),
847 format: None,
848 enum_name: None,
849 valid_codes: vec![],
850 child_group: None,
851 },
852 ],
853 }],
854 };
855
856 let json = json!({
858 "produktpaketDaten": [{
859 "produktIdentifikation": { "funktion": "5", "id": "9991000002082", "typ": "Z11" },
860 "produktMerkmal": { "code": "ZH9" }
861 }]
862 });
863
864 let errors = validate_pid_json(&json, &reqs);
865 assert!(
866 errors.is_empty(),
867 "Nested dot-path fields should be found (issue #48), got: {errors:?}"
868 );
869 }
870
871 #[test]
872 fn test_nested_dot_path_truly_missing() {
873 let reqs = PidRequirements {
874 pid: "55001".to_string(),
875 beschreibung: "Test nested paths missing".to_string(),
876 entities: vec![EntityRequirement {
877 entity: "ProduktpaketDaten".to_string(),
878 bo4e_type: "ProduktpaketDaten".to_string(),
879 companion_type: None,
880 ahb_status: "Muss".to_string(),
881 is_array: true,
882 map_key: None,
883 fields: vec![FieldRequirement {
884 bo4e_name: "produktIdentifikation.funktion".to_string(),
885 ahb_status: "X".to_string(),
886 is_companion: false,
887 field_type: "data".to_string(),
888 format: None,
889 enum_name: None,
890 valid_codes: vec![],
891 child_group: None,
892 }],
893 }],
894 };
895
896 let json = json!({
898 "produktpaketDaten": [{
899 "produktIdentifikation": { "id": "123" }
900 }]
901 });
902
903 let errors = validate_pid_json(&json, &reqs);
904 assert_eq!(errors.len(), 1, "Should report missing nested field");
905 match &errors[0] {
906 PidValidationError::MissingField { field, .. } => {
907 assert_eq!(field, "produktIdentifikation.funktion");
908 }
909 other => panic!("Expected MissingField, got: {other:?}"),
910 }
911 }
912}