diff --git a/CHANGELOG.md b/CHANGELOG.md index badd7ff..6df17ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and ## [Unreleased] +### Changed +- **Test Organization**: Split integration tests into focused test files + - `tests/basic_filter_tests.rs` - Core integration tests with static test data + - `tests/edge_case_tests.rs` - Dynamic boundary condition tests +- **Test Infrastructure Enhancement**: Implemented dynamic test data generation + - Added comprehensive boundary testing for time-based filtering rules + - Self-documenting test cases with clear business rule descriptions +- **Enhanced Test Reporting**: Added aligned, formatted test output for better readability + +### Technical +- Improved test maintainability with descriptive entity names and test descriptions + +--- + ## [0.2.0] – 2025-06-28 ### Added diff --git a/README.md b/README.md index 112ecf6..554cf3d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ src/ ├── main.rs ← Lambda entry point & business logic ├── domain.rs ← Domain entities (Action, Priority) tests/ -├── integration_tests.rs ← End-to-end lambda testing +├── basic_filter_tests.rs ← Core integration tests with static data +├── edge_case_tests.rs ← Dynamic boundary condition tests testdata/ ├── *.json ← Test input files scripts/ @@ -82,17 +83,38 @@ The Lambda applies these filtering and processing rules: - Deduplication behavior with priority conflicts - Date parsing and filtering logic -### Integration Tests (`tests/integration_tests.rs`) +### Integration Tests + +**Basic Integration Tests (`tests/basic_filter_tests.rs`)** - **Real lambda execution** using `cargo lambda invoke` - **End-to-end validation** from JSON input to JSON output - **Error handling** verification (invalid enum variants) - **Order-agnostic testing** for robust HashMap-based results +**Dynamic Edge Case Tests (`tests/edge_case_tests.rs`)** +- **Boundary condition testing** with dynamic test data generation +- **Comprehensive business rule validation** (7-day/90-day boundaries) +- **Self-documenting test cases** with clear descriptions +- **Time-independent testing** that works regardless of execution date + **Test Data Files:** - `01_sample-input.json` - Basic filtering and deduplication - `02_priority-input.json` - Priority sorting validation - `03_bad-input.json` - Error handling (invalid priority variant) -- `04_edge-cases.json` - Boundary conditions and complex scenarios + +### Running Tests + +```bash +# Run all tests +cargo test + +# Run specific test suites +cargo test --test basic_filter_tests # Basic integration tests +cargo test --test edge_case_tests # Dynamic edge case tests + +# Run unit tests only +cargo test --lib +``` ## 🚀 Usage diff --git a/testdata/04_edge-cases.json b/testdata/04_edge-cases.json deleted file mode 100644 index 5dfbafa..0000000 --- a/testdata/04_edge-cases.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "entity_id": "exactly_7_days", - "last_action_time": "2025-06-20T00:00:00Z", - "next_action_time": "2025-07-10T00:00:00Z", - "priority": "urgent" - }, - { - "entity_id": "exactly_90_days", - "last_action_time": "2025-05-01T00:00:00Z", - "next_action_time": "2025-09-25T00:00:00Z", - "priority": "normal" - }, - { - "entity_id": "just_over_7_days", - "last_action_time": "2025-06-19T00:00:00Z", - "next_action_time": "2025-07-15T00:00:00Z", - "priority": "urgent" - }, - { - "entity_id": "just_under_90_days", - "last_action_time": "2025-05-01T00:00:00Z", - "next_action_time": "2025-09-24T00:00:00Z", - "priority": "normal" - }, - { - "entity_id": "duplicate_entity", - "last_action_time": "2025-05-01T00:00:00Z", - "next_action_time": "2025-07-15T00:00:00Z", - "priority": "urgent" - }, - { - "entity_id": "duplicate_entity", - "last_action_time": "2025-05-02T00:00:00Z", - "next_action_time": "2025-07-20T00:00:00Z", - "priority": "normal" - }, - { - "entity_id": "too_recent", - "last_action_time": "2025-06-21T00:00:00Z", - "next_action_time": "2025-07-10T00:00:00Z", - "priority": "urgent" - }, - { - "entity_id": "too_far_future", - "last_action_time": "2025-05-01T00:00:00Z", - "next_action_time": "2025-09-26T00:00:00Z", - "priority": "normal" - } -] \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/basic_filter_tests.rs similarity index 73% rename from tests/integration_tests.rs rename to tests/basic_filter_tests.rs index c65d324..1e987b9 100644 --- a/tests/integration_tests.rs +++ b/tests/basic_filter_tests.rs @@ -177,100 +177,6 @@ fn test_bad_input_integration() -> Result<()> { Ok(()) } -#[test] -fn test_edge_case_filtering() -> Result<()> { - // --- - // Test edge cases around the 7-day and 90-day boundaries, plus deduplication - let actions = run_lambda_invoke("testdata/04_edge-cases.json")?; - - // Based on the test data (assuming current date around 2025-06-27): - // - exactly_7_days: last_action 2025-06-20 (exactly 7 days ago) -> SHOULD PASS - // - exactly_90_days: next_action 2025-09-25 (exactly 90 days away) -> SHOULD PASS - // - just_over_7_days: last_action 2025-06-19 (8 days ago) -> SHOULD PASS - // - just_under_90_days: next_action 2025-09-24 (89 days away) -> SHOULD PASS - // - duplicate_entity (urgent): first occurrence -> SHOULD BE DEDUPLICATED OUT - // - duplicate_entity (normal): second occurrence -> SHOULD PASS (last one wins) - // - too_recent: last_action 2025-06-21 (6 days ago) -> SHOULD BE FILTERED OUT - // - too_far_future: next_action 2025-09-26 (exactly 90 days away) -> SHOULD PASS - - // We expect 6 actions after filtering and deduplication: - // 8 input - 1 too_recent (filtered) - 1 duplicate removed = 6 - ensure!( - actions.len() == 6, - "Expected 6 actions to pass edge case filters, got {}", - actions.len() - ); - - // Verify the specific actions that should pass - let entity_ids: Vec<&String> = actions.iter().map(|a| &a.entity_id).collect(); - let expected_ids = vec![ - "exactly_7_days", - "exactly_90_days", - "just_over_7_days", - "just_under_90_days", - "duplicate_entity", - "too_far_future", // <-- included because it's exactly 90 days and code uses `<=` - ]; - - for expected_id in &expected_ids { - ensure!( - entity_ids.iter().any(|id| id == expected_id), - "Expected to find entity_id '{}' in results, but didn't", - expected_id - ); - } - - // Verify the filtered out actions are not present - let filtered_ids = vec!["too_recent"]; - for filtered_id in &filtered_ids { - ensure!( - !entity_ids.iter().any(|id| id == filtered_id), - "Expected entity_id '{}' to be filtered out, but found it in results", - filtered_id - ); - } - - // Verify deduplication: duplicate_entity should appear exactly once - let duplicate_count = entity_ids.iter().filter(|&id| *id == "duplicate_entity").count(); - ensure!( - duplicate_count == 1, - "Expected exactly 1 occurrence of 'duplicate_entity', found {}", - duplicate_count - ); - - // Verify that the duplicate_entity kept the LAST occurrence (normal priority) - let duplicate_action = actions.iter().find(|a| a.entity_id == "duplicate_entity").unwrap(); - ensure!( - duplicate_action.priority == Priority::Normal, - "Expected duplicate_entity to keep the last occurrence (normal), but got {:?}", - duplicate_action.priority - ); - - // Verify priority sorting (urgent before normal) - let mut seen_normal = false; - for action in &actions { - if action.priority == Priority::Normal { - seen_normal = true; - } else if action.priority == Priority::Urgent && seen_normal { - panic!("Found urgent priority after normal priority - sorting is incorrect"); - } - } - - println!("Edge case filtering test passed:"); - println!(" {} actions passed the time filters", actions.len()); - println!(" Deduplication verified: duplicate_entity kept last occurrence (normal)"); - for (i, action) in actions.iter().enumerate() { - println!( - " {}. {} ({})", - i + 1, - action.entity_id, - if action.priority == Priority::Urgent { "urgent" } else { "normal" } - ); - } - - Ok(()) -} - #[test] fn test_empty_input_array() -> Result<()> { // --- diff --git a/tests/edge_case_tests.rs b/tests/edge_case_tests.rs new file mode 100644 index 0000000..ca45167 --- /dev/null +++ b/tests/edge_case_tests.rs @@ -0,0 +1,212 @@ +use anyhow::{ensure, Result}; +use aws_lambda_action_filter::{Action, Priority}; +use chrono::{Duration, Utc}; +use serde_json::{self, Value}; +use std::collections::HashMap; +use std::fs; +use std::process::Command; + +/// Helper function to run cargo lambda invoke and parse the result +fn run_lambda_invoke(data_file: &str) -> Result> { + // --- + let output = + Command::new("cargo").args(["lambda", "invoke", "--data-file", data_file]).output()?; + + ensure!( + output.status.success(), + "cargo lambda invoke failed with status: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout)?; + + // The output should be a JSON array of actions + let json_value: Value = serde_json::from_str(&stdout)?; + let actions: Vec = serde_json::from_value(json_value)?; + + Ok(actions) +} + +struct TestCase { + entity_id: &'static str, + next_offset: i64, // Days from now (positive = future) + last_offset: i64, // Days from now (negative = past) + priority: Priority, + should_pass: bool, // Expected to pass filtering? + description: &'static str, +} + +#[rustfmt::skip] +const EDGE_CASES: &[TestCase] = &[ + TestCase { entity_id: "dedup_first_occurrence", next_offset: 30, last_offset: -10, priority: Priority::Urgent, should_pass: true, description: "Tests deduplication (first occurrence)" }, + TestCase { entity_id: "dedup_first_occurrence", next_offset: 35, last_offset: -15, priority: Priority::Normal, should_pass: true, description: "Tests deduplication (last occurrence wins)" }, + TestCase { entity_id: "more_than_7_days_ago_fail", next_offset: 20, last_offset: -7, priority: Priority::Urgent, should_pass: false, description: "Tests 'more than 7 days ago' rule (should fail)" }, + TestCase { entity_id: "more_than_7_days_ago_pass", next_offset: 20, last_offset: -8, priority: Priority::Urgent, should_pass: true, description: "Tests 'more than 7 days ago' rule (should pass)" }, + TestCase { entity_id: "more_than_7_days_ago_pass_2", next_offset: 25, last_offset: -10, priority: Priority::Urgent, should_pass: true, description: "Tests 'more than 7 days ago' rule (should pass)" }, + TestCase { entity_id: "within_90_days_fail", next_offset: 91, last_offset: -30, priority: Priority::Normal, should_pass: false, description: "Tests 'within 90 days' rule (should fail at 91 days)" }, + TestCase { entity_id: "within_90_days_pass", next_offset: 90, last_offset: -30, priority: Priority::Normal, should_pass: true, description: "Tests 'within 90 days' rule boundary (should pass)" }, + TestCase { entity_id: "within_90_days_pass_2", next_offset: 89, last_offset: -20, priority: Priority::Normal, should_pass: true, description: "Tests 'within 90 days' rule (should pass)" }, +]; + +fn create_action( + entity_id: &str, + last_offset: i64, + next_offset: i64, + priority: Priority, +) -> Action { + // --- + let now = Utc::now(); + Action { + entity_id: entity_id.to_string(), + last_action_time: now + Duration::days(last_offset), + next_action_time: now + Duration::days(next_offset), + priority, + } +} + +fn generate_test_data() -> Result { + // --- + let actions: Vec = EDGE_CASES + .iter() + .map(|test_case| { + // --- + create_action( + test_case.entity_id, + test_case.last_offset, + test_case.next_offset, + test_case.priority.clone(), + ) + }) + .collect(); + + let json = serde_json::to_string_pretty(&actions)?; + Ok(json) +} + +fn verify_test_expectations(results: &[Action]) -> Result<()> { + // --- + let prefix = "verify_test_expectations"; + + // Convert results to a map for O(1) lookup + let result_map: HashMap<&str, &Action> = + results.iter().map(|action| (action.entity_id.as_str(), action)).collect(); + + // Iterate over test expectations and verify against results + for test_case in EDGE_CASES { + // --- + let found_in_results = result_map.contains_key(test_case.entity_id); + + match (test_case.should_pass, found_in_results) { + // --- + (true, false) => { + // --- + ensure!( + false, + "{prefix}: {} - Expected to pass but was filtered out. {}", + test_case.entity_id, + test_case.description + ); + } + (false, true) => { + // --- + ensure!( + false, + "{prefix}: {} - Expected to be filtered out but found in results. {}", + test_case.entity_id, + test_case.description + ); + } + (true, true) => { + // --- + println!("✓ {:<28}: PASS - {}", test_case.entity_id, test_case.description); + } + (false, false) => { + // --- + println!("✓ {:<28}: FILTERED - {}", test_case.entity_id, test_case.description); + } + } + } + + Ok(()) +} + +#[test] +fn test_dynamic_edge_cases() -> Result<()> { + // --- + println!("Generating dynamic edge case test data..."); + + // Generate test data with current timestamps + let test_data = generate_test_data()?; + + // Write to temporary file + let temp_file = "testdata/edge-cases-dynamic.json"; + fs::write(temp_file, &test_data)?; + + println!("Generated test data written to: {}", temp_file); + println!("Test data preview:"); + println!("{}", test_data); + println!(); + + // Run the lambda with our generated data + let results = run_lambda_invoke(temp_file)?; + + println!("Lambda returned {} actions", results.len()); + + // Verify all test expectations + verify_test_expectations(&results)?; + + // Additional verification: check expected count + // Should have 5 actions: 6 that should pass - 1 duplicate = 5 + // (dedup_first_occurrence appears twice but deduplicated to 1) + let expected_count = 5; + ensure!( + results.len() == expected_count, + "Expected {} actions after filtering and deduplication, got {}", + expected_count, + results.len() + ); + + // Verify priority sorting (urgent before normal) + let mut seen_normal = false; + for action in &results { + // --- + if action.priority == Priority::Normal { + // --- + seen_normal = true; + } else if action.priority == Priority::Urgent && seen_normal { + // --- + ensure!(false, "Found urgent priority after normal priority - sorting failed"); + } + } + + // Verify deduplication worked correctly + let duplicate_count = + results.iter().filter(|a| a.entity_id == "dedup_first_occurrence").count(); + ensure!( + duplicate_count == 1, + "Expected exactly 1 'duplicate' entity after deduplication, found {}", + duplicate_count + ); + + // Verify that the duplicate kept the last occurrence (Normal priority) + if let Some(duplicate_action) = results.iter().find(|a| a.entity_id == "duplicate") { + // --- + ensure!( + duplicate_action.priority == Priority::Normal, + "Expected duplicate entity to keep last occurrence (Normal priority), got {:?}", + duplicate_action.priority + ); + } + + // Cleanup + fs::remove_file(temp_file).ok(); + + println!(); + println!("✅ All dynamic edge case tests passed!"); + println!(" - Boundary conditions verified"); + println!(" - Deduplication working correctly"); + println!(" - Priority sorting maintained"); + + Ok(()) +}