diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index e036b12..c8d8ed2 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -42,24 +42,6 @@ jobs: name: Unit Tests needs: build runs-on: ubuntu-latest - # cargo build never touches a database (no compile-time-checked sqlx - # macros are used anywhere), but the tests do -- #[sqlx::test] spins up - # a real, freshly-migrated database per test, which needs an actual - # Postgres server to connect to and CREATE DATABASE against. - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - name: Checkout uses: actions/checkout@v4 @@ -80,9 +62,7 @@ jobs: restore-keys: rust-${{ runner.os }}- - name: Run tests - env: - DATABASE_URL: postgres://test:test@localhost:5432/test - run: cargo test --release --locked -- --test-threads=4 + run: cargo test --release --locked docker: name: Docker diff --git a/src/build_handlers.rs b/src/build_handlers.rs index 8f8122f..85ef524 100644 --- a/src/build_handlers.rs +++ b/src/build_handlers.rs @@ -222,164 +222,3 @@ async fn patch_enchantment( save_build(&state, build.clone()); Ok(Json(build)) } - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - use sqlx::PgPool; - - use super::*; - use crate::models::AghanimTiming; - - fn test_config() -> BuildConfig { - BuildConfig { - attack_types: vec![], - attributes: vec![], - item_tags: vec![], - disable_neutral_artifact_randomness: false, - disable_enchantment_randomness: false, - disable_all_neutral_rolls: false, - disabled_item_slots: vec![], - disable_aghanim_scepter_randomness: false, - disable_aghanim_shard_randomness: false, - neutral_tier_reroll_enabled: true, - aghanim_scepter_timing: AghanimTiming::Anytime, - aghanim_shard_timing: AghanimTiming::Anytime, - } - } - - fn test_state(pool: PgPool) -> AppState { - AppState { pool, builds: Arc::new(Mutex::new(HashMap::new())), neutral_tier_gate_chance: 1.0 } - } - - #[sqlx::test] - async fn post_builds_produces_a_full_build(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let Json(build) = - post_builds(State(state), Json(test_config())).await.expect("build generation should succeed"); - - assert_eq!(build.item_slots.len(), 6); - assert_eq!(build.neutral_items.len(), 5); - assert_eq!(build.enchantments.len(), 5); - let slot1_name = build.item_slots[0].item.as_ref().unwrap().name.to_lowercase(); - assert!(slot1_name.contains("boots") || slot1_name.contains("treads")); - Ok(()) - } - - #[sqlx::test] - async fn reroll_hero_changes_hero_and_keeps_build_id(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); - let original_id = build.id.clone(); - - let Json(rerolled) = - reroll_hero(State(state), Path(original_id.clone())).await.expect("reroll should succeed"); - - assert_eq!(rerolled.id, original_id); - // Enchantments must stay consistent with whichever hero ended up on the build. - for stage in &rerolled.enchantments { - if let Some(resolved) = &stage.resolved { - assert!(resolved.attributes.contains(&rerolled.hero.primary_attribute)); - } - } - // Neutral items must have been re-rolled fresh (attempts populated again). - assert!(rerolled.neutral_items.iter().all(|s| s.attempts.len() == 2)); - Ok(()) - } - - #[sqlx::test] - async fn reroll_item_slot_only_touches_the_requested_slot(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); - - let Json(rerolled) = reroll_item_slot(State(state), Path((build.id.clone(), 3))) - .await - .expect("reroll should succeed"); - - for original_slot in &build.item_slots { - if original_slot.slot == 3 { - continue; - } - let same_slot = rerolled.item_slots.iter().find(|s| s.slot == original_slot.slot).unwrap(); - assert_eq!(same_slot.item.as_ref().map(|i| i.id), original_slot.item.as_ref().map(|i| i.id)); - } - Ok(()) - } - - #[sqlx::test] - async fn reroll_item_slot_one_stays_boots(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); - - let Json(rerolled) = - reroll_item_slot(State(state), Path((build.id.clone(), 1))).await.expect("reroll should succeed"); - - let name = rerolled.item_slots[0].item.as_ref().unwrap().name.to_lowercase(); - assert!(name.contains("boots") || name.contains("treads")); - Ok(()) - } - - #[sqlx::test] - async fn unknown_build_id_returns_not_found(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let result = reroll_hero(State(state), Path("does-not-exist".to_string())).await; - assert!(matches!(result, Err(AppError::NotFound))); - Ok(()) - } - - #[sqlx::test] - async fn patch_item_slot_marks_slot_manual(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool.clone()); - let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); - - let (item_id,): (i32,) = sqlx::query_as("SELECT id FROM items WHERE source = 'shop' LIMIT 1") - .fetch_one(&pool) - .await?; - - let Json(patched) = patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id })) - .await - .expect("patch should succeed"); - - let slot = patched.item_slots.iter().find(|s| s.slot == 2).unwrap(); - assert!(slot.is_manual); - assert_eq!(slot.item.as_ref().unwrap().id, item_id); - Ok(()) - } - - #[sqlx::test] - async fn patch_item_slot_rejects_unknown_item_id(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool); - let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); - - let result = - patch_item_slot(State(state), Path((build.id.clone(), 1)), Json(ItemIdBody { item_id: -1 })).await; - assert!(matches!(result, Err(AppError::BadRequest(_)))); - Ok(()) - } - - #[sqlx::test] - async fn aghanim_result_reflects_item_slots_after_manual_patch(pool: PgPool) -> sqlx::Result<()> { - let state = test_state(pool.clone()); - let mut config = test_config(); - config.disable_aghanim_scepter_randomness = false; - let Json(build) = post_builds(State(state.clone()), Json(config)).await.unwrap(); - - let scepter_id: Option<(i32,)> = - sqlx::query_as("SELECT id FROM items WHERE name = 'Aghanim''s Scepter'").fetch_optional(&pool).await?; - let Some((scepter_id,)) = scepter_id else { - // Scepter isn't seeded in this environment -- nothing to verify. - return Ok(()); - }; - - let Json(patched) = - patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id: scepter_id })) - .await - .expect("patch should succeed"); - - assert!(patched.aghanim_scepter.included); - assert_eq!(patched.aghanim_scepter.item.unwrap().id, scepter_id); - Ok(()) - } -} diff --git a/src/roll.rs b/src/roll.rs index 0792807..4f5f3ed 100644 --- a/src/roll.rs +++ b/src/roll.rs @@ -363,23 +363,6 @@ mod tests { } } - fn default_config() -> BuildConfig { - BuildConfig { - attack_types: vec![], - attributes: vec![], - item_tags: vec![], - disable_neutral_artifact_randomness: false, - disable_enchantment_randomness: false, - disable_all_neutral_rolls: false, - disabled_item_slots: vec![], - disable_aghanim_scepter_randomness: false, - disable_aghanim_shard_randomness: false, - neutral_tier_reroll_enabled: true, - aghanim_scepter_timing: AghanimTiming::Anytime, - aghanim_shard_timing: AghanimTiming::Anytime, - } - } - #[test] fn pick_unused_item_avoids_already_used_ids() { let pool = vec![sample_item(1), sample_item(2), sample_item(3)]; @@ -432,145 +415,6 @@ mod tests { assert!(picked.is_empty()); } - #[sqlx::test] - async fn roll_neutral_stage_carries_forward_exact_item_when_gate_lost(pool: PgPool) -> sqlx::Result<()> { - let carried = sample_item(999); - let stage = roll_neutral_stage(&pool, 3, Some(carried.clone()), true, 0.0).await.expect("roll should succeed"); - assert_eq!(stage.tier_gate_won, Some(false)); - assert_eq!(stage.resolved.map(|i| i.id), Some(carried.id)); - assert!(stage.attempts.is_empty()); - assert!(!stage.requires_manual_pick); - Ok(()) - } - - #[sqlx::test] - async fn roll_hero_respects_attack_type_filter(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.attack_types = vec![crate::models::AttackType::Melee]; - - for _ in 0..10 { - let hero = roll_hero(&pool, &config).await.expect("a melee hero should exist"); - assert_eq!(hero.attack_type, crate::models::AttackType::Melee); - } - Ok(()) - } - - #[sqlx::test] - async fn roll_hero_respects_attribute_filter(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.attributes = vec![PrimaryAttribute::Strength]; - - for _ in 0..10 { - let hero = roll_hero(&pool, &config).await.expect("a strength hero should exist"); - assert_eq!(hero.primary_attribute, PrimaryAttribute::Strength); - } - Ok(()) - } - - #[sqlx::test(migrations = false)] - async fn roll_hero_errors_when_no_heroes_match(pool: PgPool) -> sqlx::Result<()> { - sqlx::raw_sql( - "CREATE TYPE primary_attribute AS ENUM ('strength', 'agility', 'intelligence', 'universal'); - CREATE TYPE attack_type AS ENUM ('melee', 'ranged'); - CREATE TABLE heroes ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - primary_attribute primary_attribute NOT NULL, - attack_type attack_type NOT NULL, - image_url TEXT NOT NULL - );", - ) - .execute(&pool) - .await?; - - let config = default_config(); - let result = roll_hero(&pool, &config).await; - assert!(matches!(result, Err(AppError::BadRequest(_)))); - Ok(()) - } - - #[sqlx::test] - async fn eligible_shop_items_exclude_boots(pool: PgPool) -> sqlx::Result<()> { - let items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed"); - assert!(items.iter().all(|i| !i.name.to_lowercase().contains("boots") && !i.name.to_lowercase().contains("treads"))); - Ok(()) - } - - #[sqlx::test] - async fn eligible_shop_items_respect_aghanim_toggles(pool: PgPool) -> sqlx::Result<()> { - let excluded = fetch_eligible_shop_items(&pool, &[], false, false).await.expect("query should succeed"); - assert!(excluded.iter().all(|i| i.name != "Aghanim's Scepter" && i.name != "Aghanim's Shard")); - - let included = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed"); - // Not asserting presence (the seed data may or may not include them), - // just that the toggle doesn't filter them out when both are true. - assert!(included.len() >= excluded.len()); - Ok(()) - } - - #[sqlx::test] - async fn boots_items_are_boots_only(pool: PgPool) -> sqlx::Result<()> { - let items = fetch_boots_items(&pool).await.expect("query should succeed"); - assert!(items.iter().all(|i| i.name.to_lowercase().contains("boots") || i.name.to_lowercase().contains("treads"))); - Ok(()) - } - - #[sqlx::test] - async fn eligible_shop_items_filters_by_tag(pool: PgPool) -> sqlx::Result<()> { - let mut all_items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed"); - for item in all_items.iter_mut() { - hydrate_item_tags(&pool, item).await.expect("hydrate should succeed"); - } - let Some(sample_tag) = all_items.iter().flat_map(|i| i.tags.iter()).next().cloned() else { - return Ok(()); - }; - - let mut filtered = fetch_eligible_shop_items(&pool, std::slice::from_ref(&sample_tag), true, true) - .await - .expect("query should succeed"); - assert!(!filtered.is_empty()); - for item in filtered.iter_mut() { - hydrate_item_tags(&pool, item).await.expect("hydrate should succeed"); - assert!(item.tags.contains(&sample_tag)); - } - Ok(()) - } - - #[sqlx::test] - async fn neutral_items_for_tier_stay_within_their_tier_range(pool: PgPool) -> sqlx::Result<()> { - for tier in 1..=5 { - let items = fetch_neutral_items_for_tier(&pool, tier).await.expect("query should succeed"); - for item in items { - assert!(item.min_tier.unwrap() <= tier as i16); - assert!(item.max_tier.unwrap() >= tier as i16); - } - } - Ok(()) - } - - #[sqlx::test] - async fn enchantments_are_scoped_to_tier_and_attribute(pool: PgPool) -> sqlx::Result<()> { - for tier in 1..=5 { - for attribute in [ - PrimaryAttribute::Strength, - PrimaryAttribute::Agility, - PrimaryAttribute::Intelligence, - PrimaryAttribute::Universal, - ] { - let mut enchantments = fetch_enchantments_for_tier_and_attribute(&pool, tier, attribute) - .await - .expect("query should succeed"); - for enchantment in enchantments.iter_mut() { - assert!(enchantment.min_tier <= tier as i16); - assert!(enchantment.max_tier >= tier as i16); - hydrate_enchantment_attributes(&pool, enchantment).await.expect("hydrate should succeed"); - assert!(enchantment.attributes.contains(&attribute)); - } - } - } - Ok(()) - } - #[test] fn derive_aghanim_result_finds_item_in_slots() { let mut item = sample_item(42); @@ -590,116 +434,4 @@ mod tests { assert!(result.item.is_none()); } - #[sqlx::test] - async fn roll_item_slots_slot_one_is_always_boots(pool: PgPool) -> sqlx::Result<()> { - let config = default_config(); - let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed"); - let slot1 = slots.iter().find(|s| s.slot == 1).unwrap(); - let name = slot1.item.as_ref().expect("slot 1 should have an item").name.to_lowercase(); - assert!(name.contains("boots") || name.contains("treads"), "slot 1 item '{name}' is not boots"); - Ok(()) - } - - #[sqlx::test] - async fn roll_item_slots_never_put_boots_outside_slot_one(pool: PgPool) -> sqlx::Result<()> { - let config = default_config(); - let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed"); - for slot in slots.iter().filter(|s| s.slot != 1) { - if let Some(item) = &slot.item { - let name = item.name.to_lowercase(); - assert!(!name.contains("boots") && !name.contains("treads"), "slot {} got boots item '{}'", slot.slot, item.name); - } - } - Ok(()) - } - - #[sqlx::test] - async fn roll_item_slots_never_duplicates_when_enough_items_exist(pool: PgPool) -> sqlx::Result<()> { - let config = default_config(); - let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed"); - assert_eq!(slots.len(), 6); - - let ids: Vec = slots.iter().filter_map(|s| s.item.as_ref().map(|i| i.id)).collect(); - let unique: HashSet = ids.iter().copied().collect(); - assert_eq!(ids.len(), unique.len(), "item slots should not repeat the same item when enough distinct items exist"); - Ok(()) - } - - #[sqlx::test] - async fn roll_item_slots_leaves_disabled_slots_empty_and_manual(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.disabled_item_slots = vec![3, 5]; - - let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed"); - for slot in &slots { - if slot.slot == 3 || slot.slot == 5 { - assert!(slot.item.is_none()); - assert!(slot.is_manual); - } else { - assert!(!slot.is_manual); - } - } - Ok(()) - } - - #[sqlx::test] - async fn roll_neutral_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.disable_all_neutral_rolls = true; - - let stages = roll_neutral_stages(&pool, &config, 0.5).await.expect("roll should succeed"); - assert_eq!(stages.len(), 5); - assert!(stages.iter().all(|s| s.requires_manual_pick && s.resolved.is_none() && s.attempts.is_empty())); - Ok(()) - } - - #[sqlx::test] - async fn roll_neutral_stages_always_produce_two_attempts_when_gate_always_wins(pool: PgPool) -> sqlx::Result<()> { - let config = default_config(); - let stages = roll_neutral_stages(&pool, &config, 1.0).await.expect("roll should succeed"); - for stage in stages { - assert_eq!(stage.attempts.len(), 2, "tier {} should have 2 attempts", stage.tier); - assert!(stage.resolved.is_none(), "resolved should stay None until a manual pick"); - assert!(stage.requires_manual_pick); - } - Ok(()) - } - - #[sqlx::test] - async fn roll_neutral_stages_carry_forward_when_gate_always_lost(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.neutral_tier_reroll_enabled = false; - - let stages = roll_neutral_stages(&pool, &config, 0.0).await.expect("roll should succeed"); - // Tier 1 always rolls fresh (not gated); tiers 2-5 always lose the - // gate at chance=0.0, so they should carry tier 1's (unresolved) None. - assert_eq!(stages[0].tier, 1); - assert_eq!(stages[0].attempts.len(), 2); - for stage in &stages[1..] { - assert_eq!(stage.tier_gate_won, Some(false)); - assert!(stage.attempts.is_empty()); - } - Ok(()) - } - - #[sqlx::test] - async fn roll_enchantment_stages_are_guaranteed_when_enabled(pool: PgPool) -> sqlx::Result<()> { - let config = default_config(); - let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed"); - for stage in stages { - assert!(stage.resolved.is_some(), "tier {} should have a guaranteed resolved enchantment", stage.tier); - assert!(!stage.requires_manual_pick); - } - Ok(()) - } - - #[sqlx::test] - async fn roll_enchantment_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> { - let mut config = default_config(); - config.disable_enchantment_randomness = true; - - let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed"); - assert!(stages.iter().all(|s| s.resolved.is_none() && s.requires_manual_pick)); - Ok(()) - } }