From c2b8ab5164961e90cc20fd1c871f5ae4c2f1df09 Mon Sep 17 00:00:00 2001 From: Rain Date: Tue, 30 Sep 2025 06:24:21 +0000 Subject: [PATCH] [spr] initial version Created using spr 1.3.6-beta.1 --- CLAUDE.md | 7 + crates/dropshot-api-manager/src/resolved.rs | 182 +++++++----- .../integration-tests/src/common/fixtures.rs | 134 ++++++++- crates/integration-tests/src/common/mod.rs | 120 +++++++- .../tests/integration/versioned.rs | 272 ++++++++++++++++-- 5 files changed, 601 insertions(+), 114 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f2cf61 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# Instructions for dropshot-api-manager + +## General instructions + +* Always use `cargo nextest run` to run tests. Never use `cargo test`. +* Wrap comments to 80 characters. +* Always end comments with a period. diff --git a/crates/dropshot-api-manager/src/resolved.rs b/crates/dropshot-api-manager/src/resolved.rs index 2bc7154..8153eb5 100644 --- a/crates/dropshot-api-manager/src/resolved.rs +++ b/crates/dropshot-api-manager/src/resolved.rs @@ -672,7 +672,19 @@ fn resolve_api<'a>( let latest_generated = api_generated.latest_link().expect( "\"generated\" source should always have a \"latest\" link", ); - let symlink = match api_local.and_then(|l| l.latest_link()) { + let generated_version = + latest_generated.version().expect("versioned APIs have a version"); + let resolution = + by_version.get(generated_version).unwrap_or_else(|| { + panic!( + "by_version map should have a version \ + corresponding to latest_generated ({})", + latest_generated + ) + }); + + let latest_local = api_local.and_then(|l| l.latest_link()); + let symlink = match latest_local { Some(latest_local) => { if latest_local == latest_generated { None @@ -689,95 +701,123 @@ fn resolve_api<'a>( // 1. latest_local is blessed, latest_generated has the same // version as latest_local, and it has wire-compatible // changes. In that case, don't update the symlink. + // // 2. latest_local is blessed, latest_generated has the same // version as latest_local, and latest_generated has // wire-*incompatible* changes. In that case, we'd have // returned errors in the by_version map above, and we // wouldn't want to update the symlink in any case. + // // 3. latest_local is blessed, and latest_generated is // blessed but a *different* version. This means that // the latest version was retired. In this case, // we want to update the symlink to the blessed hash // corresponding to the latest generated version. + // // 4. latest_local is not blessed. In that case, we do // want to update the symlink. - let generated_version = latest_generated - .version() - .expect("versioned APIs have a version"); let local_version = latest_local .version() .expect("versioned APIs have a version"); - if let Some(resolution) = by_version.get(generated_version) - { - match resolution.kind() { - ResolutionKind::Lockstep => { - unreachable!("this is a versioned API"); - } - // Case 1 and 2 above. - ResolutionKind::Blessed - if generated_version == local_version => - { - // latest_generated is blessed and the same - // version as latest_local, so don't update the - // symlink. - None - } - ResolutionKind::Blessed => { - // latest_generated is blessed, and has a - // different version from latest_local. In this - // case, we want to update the symlink to the - // blessed version matching latest_generated - // (not latest_generated, in case it's different - // from the blessed version in a wire-compatible - // way!) - let api_blessed = - api_blessed.unwrap_or_else(|| { - panic!( - "for {}, Blessed means \ - api_blessed exists", - api.ident() - ) - }); - let blessed = api_blessed - .versions() - .get(generated_version) - .unwrap_or_else(|| { - panic!( - "for {} v{}, Blessed means \ - generated_version exists", - api.ident(), - generated_version - ); - }); - Some(Problem::LatestLinkStale { - api_ident: api.ident().clone(), - link: blessed.spec_file_name(), - found: latest_local, - }) - } - ResolutionKind::NewLocally => { - // latest_generated is not blessed, so update - // the symlink. - Some(Problem::LatestLinkStale { - api_ident: api.ident().clone(), - link: latest_generated, - found: latest_local, - }) - } + match resolution.kind() { + ResolutionKind::Lockstep => { + unreachable!("this is a versioned API"); + } + // Case 1 and 2 above. + ResolutionKind::Blessed + if generated_version == local_version => + { + // latest_generated is blessed and the same + // version as latest_local, so don't update the + // symlink. + None + } + ResolutionKind::Blessed => { + // latest_generated is blessed, and has a + // different version from latest_local. In this + // case, we want to update the symlink to the + // blessed version matching latest_generated + // (not latest_generated, in case it's different + // from the blessed version in a wire-compatible + // way!) + let api_blessed = + api_blessed.unwrap_or_else(|| { + panic!( + "for {}, Blessed means \ + api_blessed exists", + api.ident() + ) + }); + let blessed = api_blessed + .versions() + .get(generated_version) + .unwrap_or_else(|| { + panic!( + "for {} v{}, Blessed means \ + generated_version exists", + api.ident(), + generated_version + ); + }); + Some(Problem::LatestLinkStale { + api_ident: api.ident().clone(), + link: blessed.spec_file_name(), + found: latest_local, + }) + } + ResolutionKind::NewLocally => { + // latest_generated is not blessed, so update + // the symlink. + Some(Problem::LatestLinkStale { + api_ident: api.ident().clone(), + link: latest_generated, + found: latest_local, + }) } - } else { - unreachable!( - "by_version map should have a version \ - corresponding to latest_generated ({})", - latest_generated - ) } } } - None => Some(Problem::LatestLinkMissing { - api_ident: api.ident().clone(), - link: latest_generated, - }), + None => { + // As in case 3 above, if the resolution is blessed, we want to + // update the symlink to the *blessed() hash corresponding to + // the latest generated version. + match resolution.kind() { + ResolutionKind::Lockstep => { + unreachable!("this is a versioned API"); + } + ResolutionKind::Blessed => { + let api_blessed = api_blessed.unwrap_or_else(|| { + panic!( + "for {}, Blessed means api_blessed exists", + api.ident() + ) + }); + let blessed = api_blessed + .versions() + .get(generated_version) + .unwrap_or_else(|| { + panic!( + "for {} v{}, Blessed means \ + generated_version exists", + api.ident(), + generated_version + ); + }); + Some(Problem::LatestLinkMissing { + api_ident: api.ident().clone(), + link: blessed.spec_file_name(), + }) + } + ResolutionKind::NewLocally => { + // latest_generated is not blessed, so update + // the symlink to the generated version. + Some(Problem::LatestLinkMissing { + api_ident: api.ident().clone(), + link: latest_generated, + }) + } + } + } }; (by_version, symlink) diff --git a/crates/integration-tests/src/common/fixtures.rs b/crates/integration-tests/src/common/fixtures.rs index 4a09df8..25b1be4 100644 --- a/crates/integration-tests/src/common/fixtures.rs +++ b/crates/integration-tests/src/common/fixtures.rs @@ -163,9 +163,7 @@ pub mod versioned_health { use super::*; use dropshot_api_manager_types::api_versions; - api_versions!( - [(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL),] - ); + api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL)]); #[dropshot::api_description] pub trait VersionedHealthApi { @@ -457,7 +455,7 @@ pub mod versioned_health_reduced { use super::*; use dropshot_api_manager_types::api_versions; - api_versions!([(2, WITH_DETAILED_STATUS), (1, INITIAL),]); + api_versions!([(2, WITH_DETAILED_STATUS), (1, INITIAL)]); #[dropshot::api_description] pub trait VersionedHealthApi { @@ -491,3 +489,131 @@ pub mod versioned_health_reduced { DependencyStatus, DetailedHealthStatus, HealthStatusV1, }; } + +/// Versioned health API fixture that skips the middle version (2.0.0). This has +/// versions 3.0.0 and 1.0.0 only, simulating retirement of an older blessed +/// version. +pub mod versioned_health_skip_middle { + use super::*; + use dropshot_api_manager_types::api_versions; + + api_versions!([(3, WITH_METRICS), (1, INITIAL)]); + + #[dropshot::api_description] + pub trait VersionedHealthApi { + type Context; + + /// Check if the service is healthy (all versions). + #[endpoint { + method = GET, + path = "/health", + operation_id = "health_check", + versions = "1.0.0".. + }] + async fn health_check( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get detailed health status (v2+, but only available in v3 since we skip v2). + #[endpoint { + method = GET, + path = "/health/detailed", + operation_id = "detailed_health_check", + versions = "3.0.0".. + }] + async fn detailed_health_check( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get service metrics (v3+). + #[endpoint { + method = GET, + path = "/metrics", + operation_id = "get_metrics", + versions = "3.0.0".. + }] + async fn get_metrics( + rqctx: RequestContext, + ) -> Result, HttpError>; + } + + // Reuse the same response types from the main versioned_health module. + pub use super::versioned_health::{ + DependencyStatus, DetailedHealthStatus, HealthStatusV1, ServiceMetrics, + }; +} + +/// Versioned health API with incompatible changes - this breaks backward +/// compatibility by changing the response schema of an existing endpoint. +pub mod versioned_health_incompatible { + use super::*; + use dropshot_api_manager_types::api_versions; + + api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL)]); + + #[dropshot::api_description] + pub trait VersionedHealthApi { + type Context; + + /// Check if the service is healthy (all versions). + #[endpoint { + method = GET, + path = "/health", + operation_id = "health_check", + versions = "1.0.0".. + }] + async fn health_check( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get detailed health status (v2+). + #[endpoint { + method = GET, + path = "/health/detailed", + operation_id = "detailed_health_check", + versions = "2.0.0".. + }] + async fn detailed_health_check( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get service metrics (v3+). + #[endpoint { + method = GET, + path = "/metrics", + operation_id = "get_metrics", + versions = "3.0.0".. + }] + async fn get_metrics( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get system info (v3+): new endpoint added to existing version. + /// + /// This breaks backward compatibility by adding a new endpoint to + /// v3.0.0. + #[endpoint { + method = GET, + path = "/system/info", + operation_id = "get_system_info", + versions = "3.0.0".. + }] + async fn get_system_info( + rqctx: RequestContext, + ) -> Result, HttpError>; + } + + /// System information response for the new endpoint. + #[derive(JsonSchema, Serialize)] + pub struct SystemInfo { + pub version: String, + pub build_time: DateTime, + pub environment: String, + } + + // Reuse response types from the main versioned_health module for other + // endpoints. + pub use super::versioned_health::{ + DependencyStatus, DetailedHealthStatus, HealthStatusV1, ServiceMetrics, + }; +} diff --git a/crates/integration-tests/src/common/mod.rs b/crates/integration-tests/src/common/mod.rs index 141b002..578c792 100644 --- a/crates/integration-tests/src/common/mod.rs +++ b/crates/integration-tests/src/common/mod.rs @@ -141,22 +141,55 @@ impl TestEnvironment { self.read_file(format!("documents/{}.json", api_ident)) } - /// Check if a document exists for a versioned API at a specific version. - pub fn versioned_document_exists( + /// Check if a document exists for a versioned API at a specific version in + /// the working copy. + pub fn versioned_local_document_exists( &self, api_ident: &str, version: &str, ) -> bool { + self.find_versioned_document(api_ident, version).is_some() + } + + /// Check that a versioned document exists for a versioned API at a specific + /// version, and is blessed. + pub fn versioned_local_and_blessed_document_exists( + &self, + api_ident: &str, + version: &str, + ) -> anyhow::Result { + let Some(path) = self.find_versioned_document(api_ident, version) + else { + return Ok(false); + }; + + // Query git on main at the blessed path (main) + let output = Self::run_git_command( + &self.workspace_root, + &["ls-tree", "-r", "--name-only", "main", path.as_str()], + )?; + // If the output equals the path, the document is present and blessed. + Ok(output.trim() == path) + } + + fn find_versioned_document( + &self, + api_ident: &str, + version: &str, + ) -> Option { + let files = self + .list_document_files() + .expect("reading document files succeeded"); + // Versioned documents are stored in subdirectories like: // documents/api/api-version-hash.json. let pattern = format!("documents/{}/{}-{}-", api_ident, api_ident, version); - let files = self - .list_document_files() - .expect("reading document files succeeded"); - files - .iter() - .any(|f| rel_path_forward_slashes(f.as_ref()).starts_with(&pattern)) + + files.iter().find_map(|f| { + let rel_path = rel_path_forward_slashes(f.as_ref()); + rel_path.starts_with(&pattern).then(|| Utf8PathBuf::from(rel_path)) + }) } /// Read the content of a versioned API document for a specific version. @@ -165,7 +198,7 @@ impl TestEnvironment { api_ident: &str, version: &str, ) -> Result { - // Find the document file that matches the version pattern + // Find the document file that matches the version pattern. let files = self.list_document_files()?; let pattern = format!("documents/{}/{}-{}-", api_ident, api_ident, version); @@ -210,6 +243,19 @@ impl TestEnvironment { )) } + /// Delete the latest symlink for a versioned API. + pub fn delete_versioned_latest_symlink( + &self, + api_ident: &str, + ) -> Result<()> { + let latest_link = self + .documents_dir() + .join(format!("{}/{}-latest.json", api_ident, api_ident)); + std::fs::remove_file(&latest_link).with_context(|| { + format!("failed to delete latest symlink: {latest_link}") + }) + } + /// Read the latest document for a versioned API. pub fn read_versioned_latest_document( &self, @@ -479,7 +525,8 @@ pub fn create_mixed_test_apis() -> Result { /// Create versioned health API with a trivial change (title/metadata updated). pub fn create_versioned_health_test_apis_with_trivial_change() -> Result { - // Create a modified API config that would produce different OpenAPI documents. + // Create a modified API config that would produce different OpenAPI + // documents. let mut config = versioned_health_test_api(); // Modify the title to create a different document signature. @@ -491,11 +538,12 @@ pub fn create_versioned_health_test_apis_with_trivial_change() .context("failed to create trivial change versioned health ManagedApis") } -/// Create versioned health API with reduced versions (simulating version removal). +/// Create versioned health API with reduced versions (simulating version +/// removal). pub fn create_versioned_health_test_apis_reduced_versions() -> Result { - // Create a configuration similar to versioned health but with fewer versions. - // We'll create a new fixture for this. + // Create a configuration similar to versioned health but with fewer + // versions. We'll create a new fixture for this. let config = ManagedApiConfig { ident: "versioned-health", versions: Versions::Versioned { @@ -514,3 +562,49 @@ pub fn create_versioned_health_test_apis_reduced_versions() ManagedApis::new(vec![config]) .context("failed to create reduced versioned health ManagedApis") } + +pub fn create_versioned_health_test_apis_skip_middle() -> Result { + // Create a configuration similar to versioned health but skipping the + // middle version. This has versions 3.0.0 and 1.0.0, simulating retirement + // of version 2.0.0. + let config = ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + // Use versions 3.0.0 and 1.0.0 (skip 2.0.0). + supported_versions: fixtures::versioned_health_skip_middle::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some("A versioned health API that skips middle version"), + ..Default::default() + }, + api_description: fixtures::versioned_health_skip_middle::versioned_health_api_mod::stub_api_description, + extra_validation: None, + }; + + ManagedApis::new(vec![config]) + .context("failed to create skip middle versioned health ManagedApis") +} + +/// Create a versioned health API with incompatible changes that break backward +/// compatibility. +pub fn create_versioned_health_test_apis_incompatible() -> Result { + // Create a configuration similar to versioned health but with incompatible + // changes that break backward compatibility. + let config = ManagedApiConfig { + ident: "versioned-health", + versions: Versions::Versioned { + supported_versions: fixtures::versioned_health_incompatible::supported_versions(), + }, + title: "Versioned Health API", + metadata: ManagedApiMetadata { + description: Some("A versioned health API with incompatible changes"), + ..Default::default() + }, + api_description: fixtures::versioned_health_incompatible::versioned_health_api_mod::stub_api_description, + extra_validation: None, + }; + + ManagedApis::new(vec![config]) + .context("failed to create incompatible versioned health ManagedApis") +} diff --git a/crates/integration-tests/tests/integration/versioned.rs b/crates/integration-tests/tests/integration/versioned.rs index d7f5365..034a280 100644 --- a/crates/integration-tests/tests/integration/versioned.rs +++ b/crates/integration-tests/tests/integration/versioned.rs @@ -8,7 +8,10 @@ use anyhow::Result; use dropshot_api_manager::test_util::{CheckResult, check_apis_up_to_date}; -use integration_tests::common::*; +use integration_tests::common::{ + create_versioned_health_test_apis_incompatible, + create_versioned_health_test_apis_skip_middle, *, +}; use openapiv3::OpenAPI; /// Test basic versioned API document generation. @@ -18,18 +21,18 @@ fn test_versioned_generate_basic() -> Result<()> { let apis = create_versioned_health_test_apis()?; // Initially, no documents should exist. - assert!(!env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(!env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(!env.versioned_document_exists("versioned-health", "3.0.0")); + assert!(!env.versioned_local_document_exists("versioned-health", "1.0.0")); + assert!(!env.versioned_local_document_exists("versioned-health", "2.0.0")); + assert!(!env.versioned_local_document_exists("versioned-health", "3.0.0")); assert!(!env.versioned_latest_document_exists("versioned-health")); // Generate the documents. env.generate_documents(&apis)?; // Now the version documents should exist. - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(env.versioned_document_exists("versioned-health", "3.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "2.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")); assert!(env.versioned_latest_document_exists("versioned-health")); // Read and validate one of the documents is valid JSON. @@ -132,15 +135,15 @@ fn test_multiple_versioned_apis() -> Result<()> { // Check that documents exist for both APIs and all their versions. // Versioned health API (3 versions). - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(env.versioned_document_exists("versioned-health", "3.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "2.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")); assert!(env.versioned_latest_document_exists("versioned-health")); // Versioned user API (3 versions). - assert!(env.versioned_document_exists("versioned-user", "1.0.0")); - assert!(env.versioned_document_exists("versioned-user", "2.0.0")); - assert!(env.versioned_document_exists("versioned-user", "3.0.0")); + assert!(env.versioned_local_document_exists("versioned-user", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-user", "2.0.0")); + assert!(env.versioned_local_document_exists("versioned-user", "3.0.0")); assert!(env.versioned_latest_document_exists("versioned-user")); // List all versioned documents for each API. @@ -168,8 +171,8 @@ fn test_mixed_lockstep_and_versioned_apis() -> Result<()> { assert!(env.lockstep_document_exists("counter")); // Check versioned APIs exist as version-specific files. - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-user", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-user", "1.0.0")); // List all document files to verify proper structure. let all_files = env.list_document_files()?; @@ -325,9 +328,9 @@ fn test_removing_api_version_fails_check() -> Result<()> { env.commit_documents()?; // Verify all versions exist. - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(env.versioned_document_exists("versioned-health", "3.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "1.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "2.0.0")); + assert!(env.versioned_local_document_exists("versioned-health", "3.0.0")); // Create API with fewer versions (simulating version removal). let reduced_apis = create_versioned_health_test_apis_reduced_versions()?; @@ -386,10 +389,28 @@ fn test_retiring_latest_blessed_version() -> Result<()> { let result = check_apis_up_to_date(env.environment(), &full_apis)?; assert_eq!(result, CheckResult::Success); - // Verify all 3 versions exist in the blessed state. - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(env.versioned_document_exists("versioned-health", "3.0.0")); + // Verify all 3 versions exist and are blessed. + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "1.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "2.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "3.0.0" + ) + .unwrap() + ); // Now remove version 3.0.0 by switching to the reduced API. // This simulates a developer deciding to remove a version that was previously blessed. @@ -408,9 +429,27 @@ fn test_retiring_latest_blessed_version() -> Result<()> { assert_eq!(result, CheckResult::Success); // Verify the v3.0.0 document was removed and v1/v2 were updated. - assert!(env.versioned_document_exists("versioned-health", "1.0.0")); - assert!(env.versioned_document_exists("versioned-health", "2.0.0")); - assert!(!env.versioned_document_exists("versioned-health", "3.0.0")); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "1.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "2.0.0" + ) + .unwrap() + ); + assert!( + !env.versioned_local_and_blessed_document_exists( + "versioned-health", + "3.0.0" + ) + .unwrap() + ); // Verify the latest document now points to v2.0.0 (the new highest version). let latest_content = @@ -418,13 +457,145 @@ fn test_retiring_latest_blessed_version() -> Result<()> { let latest_spec: OpenAPI = serde_json::from_str(&latest_content)?; assert_eq!(latest_spec.info.version, "2.0.0"); - // Commit the retired version to "approve" it. + // Commit the retired version. env.commit_documents()?; // Should still pass after committing the retired change. let result = check_apis_up_to_date(env.environment(), &reduced_apis)?; assert_eq!(result, CheckResult::Success); + // Delete the latest symlink and ensure that we need to perform updates. + env.delete_versioned_latest_symlink("versioned-health")?; + let result = check_apis_up_to_date(env.environment(), &reduced_apis)?; + assert_eq!(result, CheckResult::NeedsUpdate); + + // Regenerate documents (i.e. the symlink) and retry. + env.generate_documents(&reduced_apis)?; + let result = check_apis_up_to_date(env.environment(), &reduced_apis)?; + assert_eq!(result, CheckResult::Success); + + // Verify the latest document points to v2.0.0 as before. Note that this + // should be the blessed version, not the generated version. + let latest_content = + env.read_versioned_latest_document("versioned-health")?; + let latest_spec: OpenAPI = serde_json::from_str(&latest_content)?; + assert_eq!(latest_spec.info.version, "2.0.0"); + + // Verify we can no longer use the old full API against the new blessed + // documents. + let result = check_apis_up_to_date(env.environment(), &full_apis)?; + assert_eq!(result, CheckResult::NeedsUpdate); + + Ok(()) +} + +#[test] +fn test_retiring_older_blessed_version() -> Result<()> { + let env = TestEnvironment::new()?; + + // Start with the full versioned health API (3 versions). + let full_apis = create_versioned_health_test_apis()?; + + // Generate and commit the initial "blessed" documents. + env.generate_documents(&full_apis)?; + env.commit_documents()?; + + // Verify initial state is up-to-date. + let result = check_apis_up_to_date(env.environment(), &full_apis)?; + assert_eq!(result, CheckResult::Success); + + // Verify all 3 versions exist and are blessed. + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "1.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "2.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "3.0.0" + ) + .unwrap() + ); + + // Now remove version 2.0.0 by switching to the skip middle API. + // This simulates a developer deciding to retire an older version that was previously blessed. + let skip_middle_apis = create_versioned_health_test_apis_skip_middle()?; + + // This check should return NeedsUpdate because the v2.0.0 document exists + // and needs to be removed. + let result = check_apis_up_to_date(env.environment(), &skip_middle_apis)?; + assert_eq!(result, CheckResult::NeedsUpdate); + + // Generate documents with the retired older version. + env.generate_documents(&skip_middle_apis)?; + + // After generation, should be up-to-date with the new API definition. + let result = check_apis_up_to_date(env.environment(), &skip_middle_apis)?; + assert_eq!(result, CheckResult::Success); + + // Verify the v2.0.0 document was removed and v1/v3 remain. + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "1.0.0" + ) + .unwrap() + ); + assert!( + !env.versioned_local_and_blessed_document_exists( + "versioned-health", + "2.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "3.0.0" + ) + .unwrap() + ); + + // Verify the latest document still points to v3.0.0 (the highest version). + let latest_content = + env.read_versioned_latest_document("versioned-health")?; + let latest_spec: OpenAPI = serde_json::from_str(&latest_content)?; + assert_eq!(latest_spec.info.version, "3.0.0"); + + // Commit the retired version. + env.commit_documents()?; + + // Should still pass after committing the retired change. + let result = check_apis_up_to_date(env.environment(), &skip_middle_apis)?; + assert_eq!(result, CheckResult::Success); + + // Delete the latest symlink and ensure that we need to perform updates. + env.delete_versioned_latest_symlink("versioned-health")?; + let result = check_apis_up_to_date(env.environment(), &skip_middle_apis)?; + assert_eq!(result, CheckResult::NeedsUpdate); + + // Regenerate documents (i.e. the symlink) and retry. + env.generate_documents(&skip_middle_apis)?; + let result = check_apis_up_to_date(env.environment(), &skip_middle_apis)?; + assert_eq!(result, CheckResult::Success); + + // Verify the latest document points to v3.0.0 as before. Note that this + // should be the blessed version, not the generated version. + let latest_content = + env.read_versioned_latest_document("versioned-health")?; + let latest_spec: OpenAPI = serde_json::from_str(&latest_content)?; + assert_eq!(latest_spec.info.version, "3.0.0"); + // Verify we can no longer use the old full API against the new blessed // documents. let result = check_apis_up_to_date(env.environment(), &full_apis)?; @@ -432,3 +603,52 @@ fn test_retiring_latest_blessed_version() -> Result<()> { Ok(()) } + +#[test] +fn test_incompatible_blessed_api_change() -> Result<()> { + let env = TestEnvironment::new()?; + + // Start with the original versioned health API (3 versions). + let original_apis = create_versioned_health_test_apis()?; + + // Generate and commit the initial "blessed" documents. + env.generate_documents(&original_apis)?; + env.commit_documents()?; + + // Verify initial state is up-to-date. + let result = check_apis_up_to_date(env.environment(), &original_apis)?; + assert_eq!(result, CheckResult::Success); + + // Verify all 3 versions exist. + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "1.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "2.0.0" + ) + .unwrap() + ); + assert!( + env.versioned_local_and_blessed_document_exists( + "versioned-health", + "3.0.0" + ) + .unwrap() + ); + + // Now introduce incompatible changes. This adds a new endpoint, which + // (while forward-compatible) we treat as a breaking change. + let incompatible_apis = create_versioned_health_test_apis_incompatible()?; + + // This check should return Failures. + let result = check_apis_up_to_date(env.environment(), &incompatible_apis)?; + assert_eq!(result, CheckResult::Failures); + + Ok(()) +}