#!/usr/bin/env bats # # SITER Solar Analysis - BATS Test Suite # Copyright (C) 2026 Known Element # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # # BATS tests for siter-solar-analysis.sh # Run with: bats tests/ # SCRIPT_PATH="/app/solar-analysis/siter-solar-analysis.sh" # Helper function to check if rate limited is_rate_limited() { local output="$1" [[ "$output" == *"429"* ]] || [[ "$output" == *"OVER_RATE_LIMIT"* ]] || [[ "$output" == *"rate limit"* ]] } ####################################### # UNIT TESTS - Script validation ####################################### @test "script exists and is executable" { [ -f "$SCRIPT_PATH" ] [ -x "$SCRIPT_PATH" ] } @test "script has valid bash syntax" { bash -n "$SCRIPT_PATH" } @test "script passes shellcheck" { command -v shellcheck >/dev/null 2>&1 || skip "shellcheck not installed" run shellcheck -s bash "$SCRIPT_PATH" [ "$status" -eq 0 ] } @test "dependencies are available (curl, jq, bc)" { command -v curl >/dev/null 2>&1 command -v jq >/dev/null 2>&1 command -v bc >/dev/null 2>&1 } ####################################### # UNIT TESTS - Help and version ####################################### @test "help option displays usage" { run "$SCRIPT_PATH" --help [ "$status" -eq 0 ] [[ "$output" == *"SITER Solar Analysis"* ]] [[ "$output" == *"USAGE:"* ]] [[ "$output" == *"--scenarios"* ]] [[ "$output" == *"NREL_API_KEY"* ]] } @test "help does NOT show --api-key option (security)" { run "$SCRIPT_PATH" --help [ "$status" -eq 0 ] [[ "$output" != *"--api-key"* ]] [[ "$output" != *"-k KEY"* ]] } @test "version option displays version" { run "$SCRIPT_PATH" --version [ "$status" -eq 0 ] [[ "$output" =~ "siter-solar-analysis version "[0-9]+\.[0-9]+\.[0-9]+ ]] } @test "short -h option shows help" { run "$SCRIPT_PATH" -h [ "$status" -eq 0 ] [[ "$output" == *"USAGE:"* ]] } @test "short -v option shows version" { run "$SCRIPT_PATH" -v [ "$status" -eq 0 ] [[ "$output" == *"version"* ]] } ####################################### # UNIT TESTS - Input validation ####################################### @test "invalid option returns error" { run "$SCRIPT_PATH" --invalid-option [ "$status" -eq 1 ] [[ "$output" == *"Unknown option"* ]] } @test "invalid latitude (non-numeric) returns error" { run "$SCRIPT_PATH" --lat "invalid" [ "$status" -eq 1 ] [[ "$output" == *"Invalid"* ]] || [[ "$output" == *"must be a number"* ]] } @test "invalid latitude (empty string) returns error" { run "$SCRIPT_PATH" --lat "" [ "$status" -eq 1 ] } @test "latitude too high (>90) returns error" { run "$SCRIPT_PATH" --lat "91" [ "$status" -eq 1 ] [[ "$output" == *"Invalid"* ]] || [[ "$output" == *"between"* ]] } @test "latitude too low (<-90) returns error" { run "$SCRIPT_PATH" --lat "-91" [ "$status" -eq 1 ] } @test "invalid longitude (non-numeric) returns error" { run "$SCRIPT_PATH" --lon "abc" [ "$status" -eq 1 ] } @test "longitude too high (>180) returns error" { run "$SCRIPT_PATH" --lon "181" [ "$status" -eq 1 ] } @test "longitude too low (<-180) returns error" { run "$SCRIPT_PATH" --lon "-181" [ "$status" -eq 1 ] } @test "invalid panels (non-numeric) returns error" { run "$SCRIPT_PATH" -p "abc" [ "$status" -eq 1 ] } @test "invalid watts (non-numeric) returns error" { run "$SCRIPT_PATH" -w "xyz" [ "$status" -eq 1 ] } @test "invalid tilt (non-numeric) returns error" { run "$SCRIPT_PATH" -t "foo" [ "$status" -eq 1 ] } @test "invalid azimuth (non-numeric) returns error" { run "$SCRIPT_PATH" -a "bar" [ "$status" -eq 1 ] } @test "invalid losses (non-numeric) returns error" { run "$SCRIPT_PATH" -l "baz" [ "$status" -eq 1 ] } ####################################### # UNIT TESTS - Boundary values ####################################### @test "latitude at min boundary (-90) is valid" { run "$SCRIPT_PATH" --lat "-90" --lon "0" # Should not fail validation (API may fail, but not input validation) if [ "$status" -eq 1 ]; then [[ "$output" != *"Invalid latitude"* ]] fi } @test "latitude at max boundary (90) is valid" { run "$SCRIPT_PATH" --lat "90" --lon "0" if [ "$status" -eq 1 ]; then [[ "$output" != *"Invalid latitude"* ]] fi } @test "longitude at min boundary (-180) is valid" { run "$SCRIPT_PATH" --lat "0" --lon "-180" if [ "$status" -eq 1 ]; then [[ "$output" != *"Invalid longitude"* ]] fi } @test "longitude at max boundary (180) is valid" { run "$SCRIPT_PATH" --lat "0" --lon "180" if [ "$status" -eq 1 ]; then [[ "$output" != *"Invalid longitude"* ]] fi } @test "zero panels is handled" { run "$SCRIPT_PATH" -p 0 # Should either error or produce 0kW system [ "$status" -eq 1 ] || [[ "$output" == *"0.0 kW"* ]] || [[ "$output" == *"0 kW"* ]] } @test "negative panels is handled" { run "$SCRIPT_PATH" -p -5 # Should fail validation [ "$status" -eq 1 ] } @test "negative watts is handled" { run "$SCRIPT_PATH" -w -400 # Should fail or be handled gracefully [ "$status" -eq 1 ] || true } ####################################### # UNIT TESTS - Environment variables ####################################### @test "NREL_API_KEY from environment is used" { # This test uses DEMO_KEY via environment NREL_API_KEY=DEMO_KEY run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] } ####################################### # INTEGRATION TESTS - API calls (require network) # These tests may be skipped if rate limited ####################################### @test "JSON output is valid JSON with NREL API" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] echo "$output" | jq . >/dev/null } @test "JSON output has required system fields" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] echo "$output" | jq -e '.system.capacity_kw' >/dev/null echo "$output" | jq -e '.system.panels' >/dev/null echo "$output" | jq -e '.system.panel_watts' >/dev/null } @test "JSON output has required production fields" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] echo "$output" | jq -e '.production.annual_kwh' >/dev/null echo "$output" | jq -e '.production.monthly_kwh' >/dev/null echo "$output" | jq -e '.production.capacity_factor' >/dev/null } @test "JSON output has required financial fields" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] echo "$output" | jq -e '.financials.annual_kwh' >/dev/null echo "$output" | jq -e '.financials.monthly_savings' >/dev/null echo "$output" | jq -e '.financials.payback_years' >/dev/null } @test "text output shows expected sections" { run "$SCRIPT_PATH" if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] [[ "$output" == *"SITER SOLAR PROJECT"* ]] [[ "$output" == *"NREL PVWATTS ANALYSIS"* ]] [[ "$output" == *"System Configuration:"* ]] [[ "$output" == *"FINANCIAL ANALYSIS"* ]] [[ "$output" == *"ROI ANALYSIS"* ]] [[ "$output" == *"PAYBACK PERIOD"* ]] } @test "monthly production table shows all months" { run "$SCRIPT_PATH" if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] for month in Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec; do [[ "$output" == *"$month"* ]] done [[ "$output" == *"ANNUAL"* ]] } @test "custom panel configuration works" { run "$SCRIPT_PATH" -p 20 -w 400 if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] [[ "$output" == *"20 × 400W"* ]] [[ "$output" == *"8.0 kW"* ]] || [[ "$output" == *"8 kW"* ]] } @test "scenarios mode shows header" { run "$SCRIPT_PATH" --scenarios if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] [[ "$output" == *"SYSTEM SIZE SCENARIOS"* ]] } @test "scenarios shows panel options" { run "$SCRIPT_PATH" --scenarios if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] [[ "$output" == *"250W"* ]] [[ "$output" == *"400W"* ]] } @test "scenarios JSON output is array" { run "$SCRIPT_PATH" --scenarios --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local type type=$(echo "$output" | jq -r 'type') [ "$type" == "array" ] } @test "verbose flag enables info output" { run "$SCRIPT_PATH" --verbose if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] } @test "location from SITER_LAT/SITER_LON environment variables" { SITER_LAT=32.77 SITER_LON=-96.79 run "$SCRIPT_PATH" if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] [[ "$output" == *"32.77"* ]] [[ "$output" == *"-96.79"* ]] } ####################################### # UNIT TESTS - Financial calculations ####################################### @test "financial calculations produce numeric values" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local annual_kwh monthly_savings payback_years annual_kwh=$(echo "$output" | jq -r '.production.annual_kwh') monthly_savings=$(echo "$output" | jq -r '.financials.monthly_savings') payback_years=$(echo "$output" | jq -r '.financials.payback_years') # Must be numeric [[ "$annual_kwh" =~ ^[0-9]+$ ]] [[ "$monthly_savings" =~ ^[0-9]+\.[0-9]+$ ]] [[ "$payback_years" =~ ^[0-9]+\.[0-9]+$ ]] } @test "annual production is positive" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local annual_kwh annual_kwh=$(echo "$output" | jq -r '.production.annual_kwh') [ "$annual_kwh" -gt 0 ] } @test "monthly savings is positive" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local monthly_savings monthly_savings=$(echo "$output" | jq -r '.financials.monthly_savings') # Use bc for floating point comparison (( $(echo "$monthly_savings > 0" | bc -l) )) } @test "payback period is reasonable (1-30 years)" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local payback_years payback_years=$(echo "$output" | jq -r '.financials.payback_years') # Payback should be between 1 and 30 years for a viable project (( $(echo "$payback_years >= 1" | bc -l) )) (( $(echo "$payback_years <= 30" | bc -l) )) } @test "self-consumption value is greater than export value" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local self_consumption_value export_value self_consumption_value=$(echo "$output" | jq -r '.financials.self_consumption_value') export_value=$(echo "$output" | jq -r '.financials.export_value') # Self-consumption rate ($0.085) is higher than export rate ($0.04) (( $(echo "$self_consumption_value > $export_value" | bc -l) )) } @test "capacity factor is in reasonable range" { run "$SCRIPT_PATH" --json if is_rate_limited "$output"; then skip "API rate limited" fi [ "$status" -eq 0 ] local capacity_factor capacity_factor=$(echo "$output" | jq -r '.production.capacity_factor') # Capacity factor should be between 10% and 25% for fixed solar (( $(echo "$capacity_factor >= 10" | bc -l) )) (( $(echo "$capacity_factor <= 25" | bc -l) )) } ####################################### # ERROR HANDLING TESTS ####################################### @test "invalid API key returns error (not 200)" { # Use a clearly invalid API key NREL_API_KEY="invalid_key_12345_test" run "$SCRIPT_PATH" [ "$status" -eq 1 ] } @test "missing argument after option returns error" { run "$SCRIPT_PATH" --lat [ "$status" -eq 1 ] } @test "missing argument after -p returns error" { run "$SCRIPT_PATH" -p [ "$status" -eq 1 ] } @test "missing argument after -w returns error" { run "$SCRIPT_PATH" -w [ "$status" -eq 1 ] }