1use crate::events::{
8 EventLineageRecord, EventQuarantineRecord, EventTelemetryRecord, EventValidationEvidence,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::path::Path;
14use traverse_registry::{
15 DriftEvidence, EventProductDescriptor, EventProductErrorCode, EventProductRegistry,
16 LookupScope, ObservedEventInteraction, ObservedLineageStore, ObservedRole, RegistryScope,
17 validate_event_product_descriptor,
18};
19
20#[derive(Debug, Clone, Default)]
22pub struct CatalogDriftReconciler {
23 registry: EventProductRegistry,
24 observed: ObservedLineageStore,
25 observed_keys: BTreeSet<(String, String)>,
26}
27
28impl CatalogDriftReconciler {
29 #[must_use]
30 pub fn new(registry: EventProductRegistry) -> Self {
31 Self {
32 registry,
33 observed: ObservedLineageStore::new(),
34 observed_keys: BTreeSet::new(),
35 }
36 }
37
38 #[must_use]
39 pub fn registry(&self) -> &EventProductRegistry {
40 &self.registry
41 }
42
43 #[must_use]
44 pub fn observed(&self) -> &ObservedLineageStore {
45 &self.observed
46 }
47
48 pub fn observe_publication(
50 &mut self,
51 event_id: &str,
52 event_version: &str,
53 capability_id: &str,
54 observed_at: &str,
55 ) {
56 self.observed_keys
57 .insert((event_id.to_string(), event_version.to_string()));
58 let declared = declared_capability_ids(
59 &self.registry,
60 event_id,
61 event_version,
62 ObservedRole::Publisher,
63 );
64 self.observed.record(
65 ObservedEventInteraction {
66 event_id: event_id.to_string(),
67 event_version: event_version.to_string(),
68 capability_id: capability_id.to_string(),
69 role: ObservedRole::Publisher,
70 observed_at: observed_at.to_string(),
71 },
72 &declared,
73 );
74 }
75
76 pub fn observe_consumption(
78 &mut self,
79 event_id: &str,
80 event_version: &str,
81 capability_id: &str,
82 observed_at: &str,
83 ) {
84 self.observed_keys
85 .insert((event_id.to_string(), event_version.to_string()));
86 let declared = declared_capability_ids(
87 &self.registry,
88 event_id,
89 event_version,
90 ObservedRole::Subscriber,
91 );
92 self.observed.record(
93 ObservedEventInteraction {
94 event_id: event_id.to_string(),
95 event_version: event_version.to_string(),
96 capability_id: capability_id.to_string(),
97 role: ObservedRole::Subscriber,
98 observed_at: observed_at.to_string(),
99 },
100 &declared,
101 );
102 }
103
104 pub fn reconcile_broker_lineage(&mut self, lineage: &[EventLineageRecord], observed_at: &str) {
106 for record in lineage {
107 self.observe_publication(
108 &record.contract_id,
109 &record.contract_version,
110 &record.producer_id,
111 observed_at,
112 );
113 self.observe_consumption(
114 &record.contract_id,
115 &record.contract_version,
116 &record.consumer_id,
117 observed_at,
118 );
119 }
120 }
121
122 #[must_use]
124 pub fn unresolved_drift(&self) -> Vec<DriftEvidence> {
125 let mut keys = self.observed_keys.clone();
126 for descriptor in self.registry.discover(LookupScope::PreferPrivate) {
127 keys.insert((
128 descriptor.contract.id.clone(),
129 descriptor.contract.version.clone(),
130 ));
131 }
132 keys.into_iter()
133 .flat_map(|(event_id, event_version)| {
134 self.observed
135 .drift_for(&event_id, &event_version)
136 .into_iter()
137 .cloned()
138 })
139 .collect()
140 }
141}
142
143fn declared_capability_ids(
144 registry: &EventProductRegistry,
145 event_id: &str,
146 event_version: &str,
147 role: ObservedRole,
148) -> Vec<String> {
149 let Some(descriptor) = find_descriptor(registry, event_id, event_version) else {
150 return Vec::new();
151 };
152 match role {
153 ObservedRole::Publisher => descriptor
154 .contract
155 .publishers
156 .iter()
157 .map(|reference| reference.capability_id.clone())
158 .collect(),
159 ObservedRole::Subscriber => descriptor
160 .contract
161 .subscribers
162 .iter()
163 .map(|reference| reference.capability_id.clone())
164 .collect(),
165 }
166}
167
168fn find_descriptor<'a>(
169 registry: &'a EventProductRegistry,
170 event_id: &str,
171 event_version: &str,
172) -> Option<&'a EventProductDescriptor> {
173 registry
174 .find_exact(RegistryScope::Private, event_id, event_version)
175 .or_else(|| registry.find_exact(RegistryScope::Public, event_id, event_version))
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum MigrationExitFindingCode {
182 MissingProducerTelemetry,
183 MissingConsumerTelemetry,
184 UnresolvedDrift,
185 InvalidEventFinding,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct MigrationExitFinding {
190 pub code: MigrationExitFindingCode,
191 pub detail: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct MigrationExitEvidence {
197 pub run_id: String,
198 pub clean: bool,
199 pub finding_count: usize,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct MigrationExitReport {
204 pub permitted: bool,
205 pub findings: Vec<MigrationExitFinding>,
206 pub evidence: MigrationExitEvidence,
207 pub previous_run_clean: bool,
208}
209
210#[must_use]
212pub fn evaluate_migration_exit(
213 registry: &EventProductRegistry,
214 reconciler: &CatalogDriftReconciler,
215 validation_evidence: &[EventValidationEvidence],
216 quarantine: &[EventQuarantineRecord],
217 telemetry: &[EventTelemetryRecord],
218 previous: Option<&MigrationExitEvidence>,
219 run_id: &str,
220) -> MigrationExitReport {
221 let mut findings = Vec::new();
222
223 for descriptor in registry.discover(LookupScope::PreferPrivate) {
227 let contract_id = descriptor.contract.id.as_str();
228 let contract_version = descriptor.contract.version.as_str();
229 let has_publish_telemetry = telemetry.iter().any(|record| {
230 record.operation == "traverse.event.publish"
231 && record.contract_id == contract_id
232 && record.contract_version == contract_version
233 });
234 if !descriptor.contract.publishers.is_empty() && !has_publish_telemetry {
235 findings.push(MigrationExitFinding {
236 code: MigrationExitFindingCode::MissingProducerTelemetry,
237 detail: format!(
238 "declared producers for {contract_id}@{contract_version} have no publish telemetry"
239 ),
240 });
241 }
242
243 for subscriber in &descriptor.contract.subscribers {
244 let has_delivery = telemetry.iter().any(|record| {
245 record.operation == "traverse.event.delivery"
246 && record.contract_id == contract_id
247 && record.contract_version == contract_version
248 && record.consumer_id.as_deref() == Some(subscriber.capability_id.as_str())
249 });
250 if !has_delivery {
251 findings.push(MigrationExitFinding {
252 code: MigrationExitFindingCode::MissingConsumerTelemetry,
253 detail: format!(
254 "declared consumer '{}' for {contract_id}@{contract_version} has no delivery telemetry",
255 subscriber.capability_id
256 ),
257 });
258 }
259 }
260 }
261
262 for evidence in reconciler.unresolved_drift() {
263 findings.push(MigrationExitFinding {
264 code: MigrationExitFindingCode::UnresolvedDrift,
265 detail: format!(
266 "{:?} capability '{}' on {}@{}",
267 evidence.kind, evidence.capability_id, evidence.event_id, evidence.event_version
268 ),
269 });
270 }
271
272 for evidence in validation_evidence {
273 findings.push(MigrationExitFinding {
274 code: MigrationExitFindingCode::InvalidEventFinding,
275 detail: format!(
276 "validation diagnostic for {}@{} ({})",
277 evidence.contract_id,
278 evidence.version,
279 evidence
280 .diagnostics
281 .first()
282 .map_or("unknown", |diagnostic| diagnostic.code)
283 ),
284 });
285 }
286 for record in quarantine {
287 findings.push(MigrationExitFinding {
288 code: MigrationExitFindingCode::InvalidEventFinding,
289 detail: format!(
290 "quarantine recorded for {}@{}",
291 record.evidence.contract_id, record.evidence.version
292 ),
293 });
294 }
295
296 let clean = findings.is_empty();
297 let finding_count = findings.len();
298 let previous_run_clean = previous.is_some_and(|evidence| evidence.clean);
299 let permitted = clean && previous_run_clean;
300
301 MigrationExitReport {
302 permitted,
303 findings,
304 evidence: MigrationExitEvidence {
305 run_id: run_id.to_string(),
306 clean,
307 finding_count,
308 },
309 previous_run_clean,
310 }
311}
312
313#[derive(Debug, Clone, Deserialize)]
315struct FixtureManifest {
316 fixtures: Vec<FixtureEntry>,
317}
318
319#[derive(Debug, Clone, Deserialize)]
320struct FixtureEntry {
321 file: String,
322 expect: String,
323 #[serde(default)]
324 error_code: Option<String>,
325 #[serde(default)]
326 existing: Option<String>,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct FixtureConformanceFailure {
331 pub file: String,
332 pub detail: String,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct FixtureConformanceReport {
337 pub passed: usize,
338 pub failures: Vec<FixtureConformanceFailure>,
339}
340
341pub fn run_descriptor_fixture_conformance(
348 fixtures_dir: &Path,
349) -> Result<FixtureConformanceReport, String> {
350 let manifest_path = fixtures_dir.join("MANIFEST.json");
351 let manifest_text = fs::read_to_string(&manifest_path)
352 .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
353 let manifest: FixtureManifest = serde_json::from_str(&manifest_text)
354 .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
355
356 let mut passed = 0;
357 let mut failures = Vec::new();
358 let mut loaded: BTreeMap<String, EventProductDescriptor> = BTreeMap::new();
359
360 for entry in &manifest.fixtures {
361 let path = fixtures_dir.join(&entry.file);
362 let text = match fs::read_to_string(&path) {
363 Ok(text) => text,
364 Err(error) => {
365 failures.push(FixtureConformanceFailure {
366 file: entry.file.clone(),
367 detail: format!("failed to read fixture: {error}"),
368 });
369 continue;
370 }
371 };
372 let descriptor: EventProductDescriptor = match serde_json::from_str(&text) {
373 Ok(descriptor) => descriptor,
374 Err(error) => {
375 failures.push(FixtureConformanceFailure {
376 file: entry.file.clone(),
377 detail: format!("failed to parse fixture JSON: {error}"),
378 });
379 continue;
380 }
381 };
382
383 let existing = entry
384 .existing
385 .as_ref()
386 .and_then(|existing_file| loaded.get(existing_file));
387 let result = validate_event_product_descriptor(&descriptor, existing);
388 match (entry.expect.as_str(), result) {
389 ("accept", Ok(())) => {
390 passed += 1;
391 loaded.insert(entry.file.clone(), descriptor);
392 }
393 ("accept", Err(failure)) => failures.push(FixtureConformanceFailure {
394 file: entry.file.clone(),
395 detail: format!(
396 "expected accept, got {:?}",
397 failure
398 .errors
399 .first()
400 .map_or(EventProductErrorCode::MissingSupportRoute, |error| error
401 .code)
402 ),
403 }),
404 ("reject", Ok(())) => failures.push(FixtureConformanceFailure {
405 file: entry.file.clone(),
406 detail: "expected reject, got accept".to_string(),
407 }),
408 ("reject", Err(failure)) => {
409 let actual = failure
410 .errors
411 .first()
412 .map(|error| format!("{:?}", error.code));
413 if entry.error_code.as_ref() == actual.as_ref() {
414 passed += 1;
415 } else {
416 failures.push(FixtureConformanceFailure {
417 file: entry.file.clone(),
418 detail: format!(
419 "expected error_code {:?}, got {:?}",
420 entry.error_code, actual
421 ),
422 });
423 }
424 }
425 (other, _) => failures.push(FixtureConformanceFailure {
426 file: entry.file.clone(),
427 detail: format!("unsupported expect value '{other}'"),
428 }),
429 }
430 }
431
432 Ok(FixtureConformanceReport { passed, failures })
433}
434
435pub fn validate_event_product_file(path: &Path) -> Result<EventProductDescriptor, String> {
442 let text = fs::read_to_string(path)
443 .map_err(|error| format!("failed to read event product descriptor: {error}"))?;
444 let descriptor: EventProductDescriptor = serde_json::from_str(&text)
445 .map_err(|error| format!("failed to parse event product descriptor: {error}"))?;
446 validate_event_product_descriptor(&descriptor, None).map_err(|failure| {
447 failure
448 .errors
449 .into_iter()
450 .map(|error| format!("{:?}: {}", error.code, error.message))
451 .collect::<Vec<_>>()
452 .join("; ")
453 })?;
454 Ok(descriptor)
455}
456
457#[cfg(test)]
458#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
459mod tests {
460 use super::*;
461 use crate::events::validation::EventValidationDiagnostic;
462 use traverse_contracts::EventContract;
463 use traverse_registry::{
464 DataClassification, DriftKind, EventExposureClass, EventProductRegistration,
465 FieldClassification,
466 };
467
468 fn sample_descriptor(
469 event_id: &str,
470 publisher: &str,
471 subscriber: Option<&str>,
472 ) -> EventProductDescriptor {
473 let mut contract: EventContract = serde_json::from_str(
474 r#"{
475 "kind":"event_contract",
476 "schema_version":"1.0.0",
477 "id":"placeholder",
478 "namespace":"content.comments",
479 "name":"comment-draft-created",
480 "version":"1.0.0",
481 "lifecycle":"active",
482 "owner":{"team":"traverse-core","contact":"test@example.com"},
483 "summary":"Published when a comment draft has been created.",
484 "description":"Governed event contract for comment draft creation.",
485 "payload":{"schema":{"type":"object","properties":{"draft_id":{"type":"string"}},"required":["draft_id"]},"compatibility":"backward-compatible"},
486 "classification":{"domain":"content.comments","bounded_context":"comments","event_type":"domain","tags":[]},
487 "publishers":[{"capability_id":"content.comments.create-comment-draft","version":"1.0.0"}],
488 "subscribers":[],
489 "policies":[],
490 "tags":[],
491 "provenance":{"source":"greenfield","author":"test","created_at":"2026-03-30T00:00:00Z"},
492 "evidence":[]
493 }"#,
494 )
495 .expect("contract json");
496 contract.id = event_id.to_string();
497 contract.name = event_id.rsplit('.').next().unwrap_or(event_id).to_string();
498 contract.publishers[0].capability_id = publisher.to_string();
499 if let Some(subscriber) = subscriber {
500 contract.subscribers = vec![traverse_contracts::CapabilityReference {
501 capability_id: subscriber.to_string(),
502 version: "1.0.0".to_string(),
503 }];
504 }
505 EventProductDescriptor {
506 contract,
507 support_route: "https://support.traverse.dev/comments".to_string(),
508 exposure: EventExposureClass::Internal,
509 field_classifications: vec![FieldClassification {
510 field_path: "draft_id".to_string(),
511 classification: DataClassification::NoClassification,
512 }],
513 replacement: None,
514 cloud_events_source: format!("traverse://capability/{publisher}"),
515 cloud_events_subject_field: Some("draft_id".to_string()),
516 deduplication_id_field: "draft_id".to_string(),
517 ordering_scope_field: None,
518 correlation_id_field: "envelope.correlation_id".to_string(),
519 causation_id_field: Some("envelope.causation_id".to_string()),
520 retention_policy: "retain 90 days".to_string(),
521 }
522 }
523
524 fn registry_with(descriptor: EventProductDescriptor) -> EventProductRegistry {
525 let mut registry = EventProductRegistry::new();
526 registry
527 .register(EventProductRegistration {
528 scope: RegistryScope::Private,
529 descriptor,
530 })
531 .expect("descriptor must register");
532 registry
533 }
534
535 #[test]
536 fn declared_producer_and_consumer_produce_no_drift() {
537 let descriptor = sample_descriptor(
538 "content.comments.comment-draft-created",
539 "content.comments.create-comment-draft",
540 Some("content.comments.notify-author"),
541 );
542 let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
543 reconciler.observe_publication(
544 "content.comments.comment-draft-created",
545 "1.0.0",
546 "content.comments.create-comment-draft",
547 "2026-08-07T00:00:00Z",
548 );
549 reconciler.observe_consumption(
550 "content.comments.comment-draft-created",
551 "1.0.0",
552 "content.comments.notify-author",
553 "2026-08-07T00:00:01Z",
554 );
555 assert!(reconciler.unresolved_drift().is_empty());
556 }
557
558 #[test]
559 fn undeclared_producer_is_reported_as_drift() {
560 let descriptor = sample_descriptor(
561 "content.comments.comment-draft-created",
562 "content.comments.create-comment-draft",
563 None,
564 );
565 let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
566 reconciler.observe_publication(
567 "content.comments.comment-draft-created",
568 "1.0.0",
569 "rogue.publisher",
570 "2026-08-07T00:00:00Z",
571 );
572 let drift = reconciler.unresolved_drift();
573 assert_eq!(drift.len(), 1);
574 assert_eq!(drift[0].kind, DriftKind::UndeclaredPublisher);
575 assert_eq!(drift[0].capability_id, "rogue.publisher");
576 }
577
578 #[test]
579 fn broker_lineage_projects_into_observed_store() {
580 let descriptor = sample_descriptor(
581 "content.comments.comment-draft-created",
582 "content.comments.create-comment-draft",
583 Some("content.comments.notify-author"),
584 );
585 let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
586 reconciler.reconcile_broker_lineage(
587 &[EventLineageRecord {
588 contract_id: "content.comments.comment-draft-created".to_string(),
589 contract_version: "1.0.0".to_string(),
590 event_id: "evt-1".to_string(),
591 producer_id: "content.comments.create-comment-draft".to_string(),
592 consumer_id: "content.comments.notify-author".to_string(),
593 subscription_id: "sub-1".to_string(),
594 cursor: "1".to_string(),
595 }],
596 "2026-08-07T00:00:00Z",
597 );
598 assert!(reconciler.unresolved_drift().is_empty());
599 assert_eq!(
600 reconciler
601 .observed()
602 .interactions_for("content.comments.comment-draft-created", "1.0.0")
603 .len(),
604 2
605 );
606 }
607
608 #[test]
609 fn migration_exit_requires_two_consecutive_clean_runs() {
610 let descriptor = sample_descriptor(
611 "content.comments.comment-draft-created",
612 "content.comments.create-comment-draft",
613 Some("content.comments.notify-author"),
614 );
615 let registry = registry_with(descriptor);
616 let mut reconciler = CatalogDriftReconciler::new(registry.clone());
617 reconciler.observe_publication(
618 "content.comments.comment-draft-created",
619 "1.0.0",
620 "content.comments.create-comment-draft",
621 "2026-08-07T00:00:00Z",
622 );
623 reconciler.observe_consumption(
624 "content.comments.comment-draft-created",
625 "1.0.0",
626 "content.comments.notify-author",
627 "2026-08-07T00:00:01Z",
628 );
629 let telemetry = vec![
630 EventTelemetryRecord {
631 operation: "traverse.event.publish",
632 outcome: "accepted",
633 contract_id: "content.comments.comment-draft-created".to_string(),
634 contract_version: "1.0.0".to_string(),
635 event_id: "evt-1".to_string(),
636 deduplication_id: None,
637 ordering_scope: None,
638 correlation_id: None,
639 causation_id: None,
640 consumer_id: None,
641 cursor: None,
642 retry_count: 0,
643 latency_ms: 0,
644 },
645 EventTelemetryRecord {
646 operation: "traverse.event.delivery",
647 outcome: "delivered",
648 contract_id: "content.comments.comment-draft-created".to_string(),
649 contract_version: "1.0.0".to_string(),
650 event_id: "evt-1".to_string(),
651 deduplication_id: None,
652 ordering_scope: None,
653 correlation_id: None,
654 causation_id: None,
655 consumer_id: Some("content.comments.notify-author".to_string()),
656 cursor: Some("1".to_string()),
657 retry_count: 0,
658 latency_ms: 0,
659 },
660 ];
661
662 let first =
663 evaluate_migration_exit(®istry, &reconciler, &[], &[], &telemetry, None, "run-1");
664 assert!(first.evidence.clean, "findings: {:?}", first.findings);
665 assert!(!first.permitted);
666
667 let second = evaluate_migration_exit(
668 ®istry,
669 &reconciler,
670 &[],
671 &[],
672 &telemetry,
673 Some(&first.evidence),
674 "run-2",
675 );
676 assert!(second.evidence.clean);
677 assert!(second.permitted);
678 assert!(second.previous_run_clean);
679 }
680
681 #[test]
682 fn migration_exit_blocks_on_invalid_event_findings() {
683 let descriptor = sample_descriptor(
684 "content.comments.comment-draft-created",
685 "content.comments.create-comment-draft",
686 None,
687 );
688 let registry = registry_with(descriptor);
689 let reconciler = CatalogDriftReconciler::new(registry.clone());
690 let evidence = EventValidationEvidence {
691 contract_id: "content.comments.comment-draft-created".to_string(),
692 version: "1.0.0".to_string(),
693 diagnostics: vec![EventValidationDiagnostic {
694 code: "EVP-001",
695 path: "/id",
696 severity: "error",
697 remediation: "provide id",
698 contract_id: "content.comments.comment-draft-created".to_string(),
699 version: "1.0.0".to_string(),
700 }],
701 };
702 let report = evaluate_migration_exit(
703 ®istry,
704 &reconciler,
705 &[evidence],
706 &[],
707 &[],
708 None,
709 "run-bad",
710 );
711 assert!(!report.evidence.clean);
712 assert!(
713 report
714 .findings
715 .iter()
716 .any(|finding| finding.code == MigrationExitFindingCode::InvalidEventFinding)
717 );
718 }
719
720 #[test]
721 fn validate_event_product_file_accepts_valid_fixture() {
722 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
723 .join("tests/fixtures/ecca-event-products/valid.json");
724 let descriptor = validate_event_product_file(&path).expect("valid fixture");
725 assert_eq!(
726 descriptor.contract.id,
727 "content.comments.comment-draft-created"
728 );
729 }
730
731 #[test]
732 fn registry_accessor_and_unknown_event_observation_are_covered() {
733 let descriptor = sample_descriptor(
734 "content.comments.comment-draft-created",
735 "content.comments.create-comment-draft",
736 None,
737 );
738 let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
739 assert_eq!(
740 reconciler
741 .registry()
742 .discover(LookupScope::PreferPrivate)
743 .len(),
744 1
745 );
746 reconciler.observe_publication(
747 "unknown.event",
748 "1.0.0",
749 "any.capability",
750 "2026-08-07T00:00:00Z",
751 );
752 assert_eq!(reconciler.unresolved_drift().len(), 1);
753 }
754
755 #[test]
756 fn public_scope_descriptors_resolve_for_declared_capabilities() {
757 let descriptor = sample_descriptor(
758 "content.comments.comment-draft-created",
759 "content.comments.create-comment-draft",
760 None,
761 );
762 let mut registry = EventProductRegistry::new();
763 registry
764 .register(EventProductRegistration {
765 scope: RegistryScope::Public,
766 descriptor,
767 })
768 .expect("public descriptor must register");
769 let mut reconciler = CatalogDriftReconciler::new(registry);
770 reconciler.observe_publication(
771 "content.comments.comment-draft-created",
772 "1.0.0",
773 "content.comments.create-comment-draft",
774 "2026-08-07T00:00:00Z",
775 );
776 assert!(reconciler.unresolved_drift().is_empty());
777 }
778
779 #[test]
780 fn migration_exit_reports_missing_consumer_telemetry_and_drift() {
781 let descriptor = sample_descriptor(
782 "content.comments.comment-draft-created",
783 "content.comments.create-comment-draft",
784 Some("content.comments.notify-author"),
785 );
786 let registry = registry_with(descriptor);
787 let mut reconciler = CatalogDriftReconciler::new(registry.clone());
788 reconciler.observe_publication(
789 "content.comments.comment-draft-created",
790 "1.0.0",
791 "rogue.publisher",
792 "2026-08-07T00:00:00Z",
793 );
794 let report =
795 evaluate_migration_exit(®istry, &reconciler, &[], &[], &[], None, "run-gaps");
796 assert!(
797 report
798 .findings
799 .iter()
800 .any(|finding| finding.code == MigrationExitFindingCode::MissingProducerTelemetry)
801 );
802 assert!(
803 report
804 .findings
805 .iter()
806 .any(|finding| finding.code == MigrationExitFindingCode::MissingConsumerTelemetry)
807 );
808 assert!(
809 report
810 .findings
811 .iter()
812 .any(|finding| finding.code == MigrationExitFindingCode::UnresolvedDrift)
813 );
814 }
815
816 #[test]
817 fn migration_exit_reports_quarantine_and_empty_diagnostic_codes() {
818 let descriptor = sample_descriptor(
819 "content.comments.comment-draft-created",
820 "content.comments.create-comment-draft",
821 None,
822 );
823 let registry = registry_with(descriptor);
824 let reconciler = CatalogDriftReconciler::new(registry.clone());
825 let evidence = EventValidationEvidence {
826 contract_id: "content.comments.comment-draft-created".to_string(),
827 version: "1.0.0".to_string(),
828 diagnostics: Vec::new(),
829 };
830 let quarantine = EventQuarantineRecord {
831 evidence: evidence.clone(),
832 };
833 let report = evaluate_migration_exit(
834 ®istry,
835 &reconciler,
836 std::slice::from_ref(&evidence),
837 std::slice::from_ref(&quarantine),
838 &[],
839 None,
840 "run-quarantine",
841 );
842 assert_eq!(
843 report
844 .findings
845 .iter()
846 .filter(|finding| finding.code == MigrationExitFindingCode::InvalidEventFinding)
847 .count(),
848 2
849 );
850 assert!(
851 report
852 .findings
853 .iter()
854 .any(|finding| finding.detail.contains("unknown"))
855 );
856 }
857
858 #[test]
859 fn validate_event_product_file_rejects_invalid_fixture() {
860 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
861 .join("tests/fixtures/ecca-event-products/reject_missing_support_route.json");
862 let error = validate_event_product_file(&path).expect_err("invalid fixture");
863 assert!(error.contains("MissingSupportRoute"));
864 }
865
866 #[test]
867 fn fixture_conformance_reports_error_and_mismatch_branches() {
868 let root =
869 std::env::temp_dir().join(format!("ecca-fixture-branches-{}", std::process::id()));
870 let _ = fs::remove_dir_all(&root);
871 fs::create_dir_all(&root).expect("temp fixture dir");
872
873 let valid = fs::read_to_string(
874 Path::new(env!("CARGO_MANIFEST_DIR"))
875 .join("tests/fixtures/ecca-event-products/valid.json"),
876 )
877 .expect("valid fixture");
878 fs::write(root.join("valid.json"), &valid).expect("write valid");
879 fs::write(root.join("broken.json"), "{not-json").expect("write broken");
880 fs::write(
881 root.join("MANIFEST.json"),
882 r#"{
883 "fixtures": [
884 {"file":"valid.json","expect":"accept"},
885 {"file":"missing.json","expect":"accept"},
886 {"file":"broken.json","expect":"accept"},
887 {"file":"valid.json","expect":"reject","error_code":"MissingSupportRoute"},
888 {"file":"valid.json","expect":"reject","error_code":"WrongCode"},
889 {"file":"valid.json","expect":"weird"}
890 ]
891 }"#,
892 )
893 .expect("write manifest");
894
895 let reject = fs::read_to_string(
897 Path::new(env!("CARGO_MANIFEST_DIR"))
898 .join("tests/fixtures/ecca-event-products/reject_missing_support_route.json"),
899 )
900 .expect("reject fixture");
901 fs::write(root.join("invalid.json"), reject).expect("write invalid");
902 fs::write(
903 root.join("MANIFEST.json"),
904 r#"{
905 "fixtures": [
906 {"file":"valid.json","expect":"accept"},
907 {"file":"missing.json","expect":"accept"},
908 {"file":"broken.json","expect":"accept"},
909 {"file":"invalid.json","expect":"accept"},
910 {"file":"valid.json","expect":"reject","error_code":"MissingSupportRoute"},
911 {"file":"invalid.json","expect":"reject","error_code":"WrongCode"},
912 {"file":"valid.json","expect":"weird"}
913 ]
914 }"#,
915 )
916 .expect("rewrite manifest");
917
918 let report = run_descriptor_fixture_conformance(&root).expect("runner must return");
919 assert!(!report.failures.is_empty());
920 assert!(
921 report
922 .failures
923 .iter()
924 .any(|failure| failure.detail.contains("failed to read fixture"))
925 );
926 assert!(
927 report
928 .failures
929 .iter()
930 .any(|failure| failure.detail.contains("failed to parse fixture JSON"))
931 );
932 assert!(
933 report
934 .failures
935 .iter()
936 .any(|failure| failure.detail.contains("expected accept, got"))
937 );
938 assert!(
939 report
940 .failures
941 .iter()
942 .any(|failure| failure.detail.contains("expected reject, got accept"))
943 );
944 assert!(
945 report
946 .failures
947 .iter()
948 .any(|failure| failure.detail.contains("expected error_code"))
949 );
950 assert!(
951 report
952 .failures
953 .iter()
954 .any(|failure| failure.detail.contains("unsupported expect value"))
955 );
956 let _ = fs::remove_dir_all(&root);
957 }
958
959 #[test]
960 fn fixture_conformance_rejects_unreadable_manifest() {
961 let root = std::env::temp_dir().join(format!(
962 "ecca-fixture-missing-manifest-{}",
963 std::process::id()
964 ));
965 let _ = fs::remove_dir_all(&root);
966 fs::create_dir_all(&root).expect("temp dir");
967 let error = run_descriptor_fixture_conformance(&root).expect_err("missing manifest");
968 assert!(error.contains("failed to read"));
969 let _ = fs::remove_dir_all(&root);
970 }
971
972 #[test]
973 fn fixture_conformance_rejects_unparsable_manifest() {
974 let root =
975 std::env::temp_dir().join(format!("ecca-fixture-bad-manifest-{}", std::process::id()));
976 let _ = fs::remove_dir_all(&root);
977 fs::create_dir_all(&root).expect("temp dir");
978 fs::write(root.join("MANIFEST.json"), "{not-json").expect("write");
979 let error = run_descriptor_fixture_conformance(&root).expect_err("bad manifest");
980 assert!(error.contains("failed to parse"));
981 let _ = fs::remove_dir_all(&root);
982 }
983
984 #[test]
985 fn validate_event_product_file_reports_io_and_parse_errors() {
986 let missing = std::env::temp_dir().join(format!(
987 "ecca-missing-descriptor-{}.json",
988 std::process::id()
989 ));
990 let _ = fs::remove_file(&missing);
991 let io_error = validate_event_product_file(&missing).expect_err("missing file");
992 assert!(io_error.contains("failed to read"));
993
994 let broken = std::env::temp_dir().join(format!(
995 "ecca-broken-descriptor-{}.json",
996 std::process::id()
997 ));
998 fs::write(&broken, "{not-json").expect("write broken");
999 let parse_error = validate_event_product_file(&broken).expect_err("bad json");
1000 assert!(parse_error.contains("failed to parse"));
1001 let _ = fs::remove_file(&broken);
1002 }
1003}