diff --git a/.gitignore b/.gitignore index cd32b6a..1272097 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ node_modules/ # Environment secrets .env .env.* -!.env.example \ No newline at end of file +!.env.example + + +/target/ +**/*.rs.bk +*.wasm \ No newline at end of file diff --git a/apexchainx_calculator/src/contract_info.rs b/apexchainx_calculator/src/contract_info.rs index 85dc7f7..dd91c94 100644 --- a/apexchainx_calculator/src/contract_info.rs +++ b/apexchainx_calculator/src/contract_info.rs @@ -82,14 +82,15 @@ pub struct ContractInfo { /// identity, version posture, and feature availability before resuming /// operations. The `schema_version` field lets consumers detect when /// new fields have been added to `ContractInfo`. +/// +/// This function intentionally bypasses `check_version()` so backend consumers +/// can observe `needs_migration == true` pre-migration during startup handshake. pub fn get_contract_info(env: &Env) -> Result { - SLACalculatorContract::check_version(env)?; - let stored_version: u32 = env .storage() .instance() .get(&crate::STORAGE_VERSION_KEY) - .unwrap_or(0); + .ok_or(SLAError::NotInitialized)?; let needs_migration = stored_version != STORAGE_VERSION; let is_paused: bool = env.storage().instance().get(&crate::PAUSED_KEY).unwrap_or(false); @@ -114,7 +115,7 @@ pub fn get_contract_info(env: &Env) -> Result { schema_version: CONTRACT_INFO_SCHEMA_VERSION, contract_name: symbol_short!("sla_calc"), contract_version: symbol_short!("0_1_0"), - storage_version: STORAGE_VERSION, + storage_version: stored_version, result_schema_version: RESULT_SCHEMA_VERSION, event_version: crate::event_schema::current_event_version(), needs_migration, @@ -253,13 +254,13 @@ mod tests { let info = get_contract_info(&env).unwrap(); assert!(!info.needs_migration); - // Corrupt the stored version: get_contract_info must now surface - // an explicit VersionMismatch error (the contract reports that it - // needs migration instead of returning stale metadata). + // Change stored version: get_contract_info returns a ContractInfo + // struct with needs_migration: true and the stored storage_version. env.storage().instance().set(&crate::STORAGE_VERSION_KEY, &99u32); - let err = get_contract_info(&env).unwrap_err(); - assert_eq!(err, crate::SLAError::VersionMismatch); + let info_mig = get_contract_info(&env).unwrap(); + assert!(info_mig.needs_migration); + assert_eq!(info_mig.storage_version, 99u32); }); } diff --git a/apexchainx_calculator/src/schema_migration_tests.rs b/apexchainx_calculator/src/schema_migration_tests.rs index 5461cb5..774d2b4 100644 --- a/apexchainx_calculator/src/schema_migration_tests.rs +++ b/apexchainx_calculator/src/schema_migration_tests.rs @@ -216,4 +216,47 @@ mod tests { panic!("get_config_bundle returned None after initialization"); } } + + // ----------------------------------------------------------------------- + // Multi-arm migration harness and chaining test pattern (#505) + // ----------------------------------------------------------------------- + + /// **#505 – Multi-arm migration chaining & idempotency test pattern.** + /// + /// Demonstrates and validates the multi-step migration pattern (`v0 -> v1 -> ...`). + /// Verifies that a contract starting at a historical storage version (`v0`) + /// advances through all sequential migration arms to reach `STORAGE_VERSION` + /// in a single `migrate()` call, and that subsequent calls are idempotent no-ops. + #[test] + fn test_multi_arm_migration_chaining_and_idempotency() { + use crate::STORAGE_VERSION; + let (env, client) = setup(); + + let admin = soroban_sdk::Address::generate(&env); + + // 1. Synthesize a v0 contract state by resetting STORAGE_VERSION_KEY to 0 + env.storage().instance().set(&crate::STORAGE_VERSION_KEY, &0u32); + + // Verify pre-migration state reports needs_migration = true + let mig_state = client.get_migration_state(); + assert_eq!(mig_state.stored_version, 0); + assert_eq!(mig_state.expected_version, STORAGE_VERSION); + assert!(mig_state.needs_migration); + + // 2. Invoke migrate() — advances v0 -> v1 (and any subsequent arms sequentially) + let result = client.try_migrate(&admin); + assert!(result.is_ok(), "migrate() failed on v0 contract state"); + + // 3. Verify post-migration state is fully updated to current STORAGE_VERSION + let post_state = client.get_migration_state(); + assert_eq!(post_state.stored_version, STORAGE_VERSION); + assert!(!post_state.needs_migration); + + // 4. Idempotency test: calling migrate() again when already current is a safe no-op + let retry_result = client.try_migrate(&admin); + assert!(retry_result.is_ok(), "second migrate() call must be a safe idempotent no-op"); + let retry_state = client.get_migration_state(); + assert_eq!(retry_state.stored_version, STORAGE_VERSION); + assert!(!retry_state.needs_migration); + } } diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 32b76b9..a8d7c79 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -3171,6 +3171,79 @@ fn test_get_history_page_with_meta_items_match_get_history_page() { } } +/// #503 - Matrix test asserting get_history_page_with_meta(offset, limit).items == get_history_page(offset, limit) +/// and has_more against a manual count across edge cases (empty history, offset beyond end, limit 0, limit exceeding remaining). +#[test] +fn test_get_history_page_equivalence_and_has_more_matrix() { + let (_env, client, actors) = setup(); + + // 1. Test empty history matrix + let empty_offsets = [0u32, 1, 10, u32::MAX]; + let limits = [0u32, 1, 2, 5, 100, u32::MAX]; + + for &offset in &empty_offsets { + for &limit in &limits { + let plain = client.get_history_page(&offset, &limit); + let meta = client.get_history_page_with_meta(&offset, &limit); + + assert_eq!( + meta.items, plain, + "empty history items mismatch at offset={} limit={}", + offset, limit + ); + assert_eq!(meta.total, 0); + assert!( + !meta.has_more, + "has_more should be false for empty history at offset={} limit={}", + offset, limit + ); + } + } + + // 2. Test populated history matrix (len = 5) + for i in 0..5u32 { + let oid = Symbol::new(&_env, &alloc::format!("PG_EQ_{}", i)); + client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10); + } + let total_len = 5u32; + + let offsets = [0u32, 1, 2, 4, 5, 6, 100, u32::MAX]; + + for &offset in &offsets { + for &limit in &limits { + let plain = client.get_history_page(&offset, &limit); + let meta = client.get_history_page_with_meta(&offset, &limit); + + // Equivalence assertion: meta.items == get_history_page(offset, limit) + assert_eq!( + meta.items, plain, + "items mismatch at offset={} limit={}", + offset, limit + ); + assert_eq!( + meta.total, total_len, + "total mismatch at offset={} limit={}", + offset, limit + ); + + // Manual calculation for has_more + let expected_has_more = if offset >= total_len { + false + } else if limit == 0 { + true + } else { + offset.saturating_add(limit.min(200)) < total_len + }; + + assert_eq!( + meta.has_more, expected_has_more, + "has_more mismatch at offset={} limit={}: expected {}, got {}", + offset, limit, expected_has_more, meta.has_more + ); + } + } +} + #[test] fn test_get_history_page_with_meta_saturating_arithmetic() { let (_env, client, actors) = setup(); @@ -7654,6 +7727,73 @@ fn test_economic_exposure_independent_of_history() { assert_eq!(exposure_before, exposure_after); } +// ============================================================ +// #504 – Custom severity view behavior pinning tests +// ============================================================ + +/// #504 - Pins current canonical-only behavior of get_economic_exposure when a custom severity is registered. +/// Currently get_economic_exposure iterates canonical_severities only; registered custom severities are omitted from breakdown. +#[test] +fn test_economic_exposure_with_custom_severity_pins_canonical_only_behavior() { + let (_env, client, actors) = setup(); + + // Register a custom severity ("warning") with valid bounds (threshold 90, penalty 5, reward 200) + client.set_custom_severity(&actors.admin, &symbol_short!("warning"), &90, &5, &200); + + let exposure = client.get_economic_exposure(); + + // Current behavior: breakdown contains only the 4 canonical severities; custom severity is omitted + assert_eq!(exposure.breakdown.len(), 4); + let has_warning = exposure + .breakdown + .iter() + .any(|e| e.severity == symbol_short!("warning")); + assert!( + !has_warning, + "get_economic_exposure currently omits custom severities" + ); + + // Totals match only the canonical sum (5700 max reward, 185 penalty rate) + assert_eq!(exposure.total_max_reward, 5700); + assert_eq!(exposure.total_penalty_per_minute, 185); +} + +/// #504 - Pins current behavior of get_severity_telemetry when calculate_sla runs against a custom severity. +/// Currently custom-severity calculate_sla calls map to index 0 (critical lane), misattributing counts to critical. +#[test] +fn test_severity_telemetry_with_custom_severity_pins_canonical_lane_attribution() { + let (env, client, actors) = setup(); + env.ledger().set_timestamp(1000); + + // Register a custom severity ("warning") + client.set_custom_severity(&actors.admin, &symbol_short!("warning"), &90, &5, &200); + + // Execute calculate_sla for custom severity causing a violation (mttr 100 > threshold 90) + client.calculate_sla( + &actors.operator, + &symbol_short!("CUST001"), + &symbol_short!("warning"), + &100, + ); + + let telemetry = client.get_severity_telemetry(); + + // The telemetry output has 5 entries (4 canonical + 1 custom) + assert_eq!(telemetry.len(), 5); + + // Current behavior: custom activity is misattributed to critical lane (index 0) + let critical = telemetry.get(0).unwrap(); + assert_eq!(critical.severity, symbol_short!("critical")); + assert_eq!(critical.calculations, 1); + assert_eq!(critical.violations, 1); + + // The custom severity entry ("warning") shows 0 calculations and 0 violations + let warning = telemetry.get(4).unwrap(); + assert_eq!(warning.severity, symbol_short!("warning")); + assert_eq!(warning.calculations, 0); + assert_eq!(warning.violations, 0); +} + // ============================================================ // #218 – Read-only healthcheck path // ============================================================ diff --git a/docs/STORAGE_KEY_MIGRATION_CHECKLIST.md b/docs/STORAGE_KEY_MIGRATION_CHECKLIST.md index e7e892d..685717e 100644 --- a/docs/STORAGE_KEY_MIGRATION_CHECKLIST.md +++ b/docs/STORAGE_KEY_MIGRATION_CHECKLIST.md @@ -35,9 +35,7 @@ the questions below before marking your PR ready for review. - [ ] **`init_missing_storage_defaults` updated.** If the key must be present after a fresh `initialize()` call, it is written there too. -- [ ] **Tests cover the migration path.** At minimum: a test that calls - `migrate()` on a contract state that lacks the new key, then reads the - key and asserts it has the expected default value. +- [ ] **Tests cover the migration path.** At minimum: a test following the multi-arm chaining test pattern in `schema_migration_tests.rs` (`test_multi_arm_migration_chaining_and_idempotency`) that calls `migrate()` on a synthesized past contract state, asserts sequential version transition and defaults, and verifies idempotency. ---