Version 1.1

This commit is contained in:
2026-02-18 08:24:54 -05:00
parent 4318c8f642
commit fdba869a8d
33 changed files with 2142 additions and 1942 deletions

21
tests/conftest.py Normal file
View File

@@ -0,0 +1,21 @@
"""Shared pytest fixtures for the Lottery Tracker test suite."""
from __future__ import annotations
import pytest
from app import create_app
@pytest.fixture()
def app():
"""Create a test Flask app."""
application = create_app()
application.config["TESTING"] = True
return application
@pytest.fixture()
def client(app):
"""Flask test client."""
return app.test_client()

215
tests/test_api.py Normal file
View File

@@ -0,0 +1,215 @@
"""Integration tests for Flask API endpoints."""
from __future__ import annotations
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# /api/health
# ---------------------------------------------------------------------------
def test_health(client):
resp = client.get("/api/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
# ---------------------------------------------------------------------------
# /api/jackpots
# ---------------------------------------------------------------------------
@patch("app.get_all_jackpots")
def test_jackpots(mock_get, client):
mock_get.return_value = {
"us": {"powerball": 500_000_000, "megaMillions": 300_000_000},
"canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000},
}
resp = client.get("/api/jackpots")
assert resp.status_code == 200
data = resp.get_json()
assert data["us"]["powerball"] == 500_000_000
# ---------------------------------------------------------------------------
# /api/calculate
# ---------------------------------------------------------------------------
def test_calculate_us(client):
resp = client.post(
"/api/calculate",
json={"jackpot": 100_000_000, "type": "us"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["country"] == "US"
assert data["originalJackpot"] == 100_000_000
def test_calculate_canadian(client):
resp = client.post(
"/api/calculate",
json={"jackpot": 50_000_000, "type": "canadian"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["country"] == "Canada"
def test_calculate_with_state(client):
resp = client.post(
"/api/calculate",
json={"jackpot": 100_000_000, "type": "us", "state": "CA"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["stateTaxRate"] == 0.133 # California
def test_calculate_missing_jackpot(client):
resp = client.post("/api/calculate", json={"type": "us"})
assert resp.status_code == 400
def test_calculate_bad_type(client):
resp = client.post("/api/calculate", json={"jackpot": 100, "type": "mars"})
assert resp.status_code == 400
def test_calculate_no_body(client):
resp = client.post("/api/calculate")
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# /api/states
# ---------------------------------------------------------------------------
def test_states_list(client):
resp = client.get("/api/states")
assert resp.status_code == 200
data = resp.get_json()
assert len(data) == 51 # 50 states + DC
codes = [s["code"] for s in data]
assert "CA" in codes
assert "TX" in codes
def test_state_by_code(client):
resp = client.get("/api/states/NY")
assert resp.status_code == 200
data = resp.get_json()
assert data["name"] == "New York"
assert data["rate"] == 0.109
def test_state_not_found(client):
resp = client.get("/api/states/ZZ")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# /api/compare
# ---------------------------------------------------------------------------
@patch("app.get_all_jackpots")
def test_compare(mock_get, client):
mock_get.return_value = {
"us": {"powerball": 500_000_000, "megaMillions": 300_000_000},
"canadian": {"lottoMax": 70_000_000, "lotto649": 20_000_000},
}
resp = client.get("/api/compare")
assert resp.status_code == 200
data = resp.get_json()
assert len(data) == 4
names = [d["name"] for d in data]
assert "Powerball" in names
# ---------------------------------------------------------------------------
# /api/calculate/breakeven
# ---------------------------------------------------------------------------
def test_breakeven(client):
resp = client.post(
"/api/calculate/breakeven",
json={"lottery": "powerball"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["breakEvenJackpot"] > 0
assert data["lottery"] == "Powerball"
def test_breakeven_unknown_lottery(client):
resp = client.post(
"/api/calculate/breakeven",
json={"lottery": "nosuchlottery"},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# /api/calculate/annuity
# ---------------------------------------------------------------------------
def test_annuity(client):
resp = client.post(
"/api/calculate/annuity",
json={"jackpot": 500_000_000, "type": "us", "years": 30},
)
assert resp.status_code == 200
data = resp.get_json()
assert len(data["schedule"]) == 30
def test_annuity_canadian(client):
resp = client.post(
"/api/calculate/annuity",
json={"jackpot": 100_000_000, "type": "canadian"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["country"] == "canadian"
assert data["schedule"][0]["tax"] == 0.0
# ---------------------------------------------------------------------------
# /api/calculate/group
# ---------------------------------------------------------------------------
def test_group_play(client):
resp = client.post(
"/api/calculate/group",
json={"jackpot": 100_000_000, "members": 4, "type": "us"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["members"] == 4
assert len(data["memberResults"]) == 4
def test_group_custom_shares(client):
resp = client.post(
"/api/calculate/group",
json={"jackpot": 100_000_000, "members": 2, "shares": [0.7, 0.3], "type": "canadian"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["memberResults"][0]["share"] == pytest.approx(0.7)
# ---------------------------------------------------------------------------
# /api/odds
# ---------------------------------------------------------------------------
def test_odds(client):
resp = client.get("/api/odds")
assert resp.status_code == 200
data = resp.get_json()
assert len(data) == 4
names = [d["name"] for d in data]
assert "Powerball" in names
assert "Mega Millions" in names

47
tests/test_config.py Normal file
View File

@@ -0,0 +1,47 @@
"""Tests for config.py."""
from __future__ import annotations
import os
from unittest.mock import patch
from config import LOTTERY_ODDS, STATE_TAX_RATES, load_config
def test_all_states_present():
assert len(STATE_TAX_RATES) == 51 # 50 states + DC
def test_all_lottery_odds_present():
assert "powerball" in LOTTERY_ODDS
assert "megaMillions" in LOTTERY_ODDS
assert "lottoMax" in LOTTERY_ODDS
assert "lotto649" in LOTTERY_ODDS
def test_load_config_defaults():
cfg = load_config()
assert cfg.debug is False
assert cfg.port == 5000
assert cfg.tax.lump_sum_rate == 0.52
assert cfg.investment.cycles == 8
@patch.dict(os.environ, {"FLASK_DEBUG": "true", "FLASK_PORT": "8080"})
def test_load_config_from_env():
cfg = load_config()
assert cfg.debug is True
assert cfg.port == 8080
@patch.dict(os.environ, {"LUMP_SUM_RATE": "0.60"})
def test_tax_config_from_env():
cfg = load_config()
assert cfg.tax.lump_sum_rate == 0.60
def test_no_tax_states():
"""Florida, Texas, Nevada, etc. should have 0% tax."""
no_tax = ["AK", "FL", "NV", "NH", "SD", "TN", "TX", "WA", "WY"]
for code in no_tax:
assert STATE_TAX_RATES[code]["rate"] == 0.0, f"{code} should have 0% tax"

View File

@@ -0,0 +1,207 @@
"""Unit tests for lottery_calculator.py — pure calculation logic."""
from __future__ import annotations
import pytest
from lottery_calculator import (
calculate_annuity,
calculate_break_even,
calculate_canadian_lottery,
calculate_group_split,
calculate_us_lottery,
)
# ---------------------------------------------------------------------------
# calculate_us_lottery
# ---------------------------------------------------------------------------
class TestCalculateUSLottery:
"""Tests for the US lottery calculation."""
def test_basic_calculation(self):
result = calculate_us_lottery(100_000_000)
assert result["country"] == "US"
assert result["originalJackpot"] == 100_000_000
def test_cash_sum_is_lump_sum_rate(self):
result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52)
assert result["cashSum"] == pytest.approx(52_000_000)
def test_federal_tax_applied(self):
result = calculate_us_lottery(100_000_000, lump_sum_rate=0.52, federal_tax_rate=0.37)
assert result["federalTax"] == pytest.approx(52_000_000 * 0.37)
def test_state_tax_applied(self):
result = calculate_us_lottery(100_000_000, state_tax_rate=0.10)
assert result["stateTaxRate"] == 0.10
def test_net_amount_usd(self):
result = calculate_us_lottery(
100_000_000, lump_sum_rate=0.50, federal_tax_rate=0.30, state_tax_rate=0.05
)
expected = 50_000_000 * (1 - 0.30 - 0.05)
assert result["netAmountUsd"] == pytest.approx(expected)
def test_cad_conversion(self):
result = calculate_us_lottery(100_000_000, usd_cad_rate=1.40)
assert result["netAmountCad"] == pytest.approx(result["netAmountUsd"] * 1.40)
def test_investment_split(self):
result = calculate_us_lottery(100_000_000, invest_percentage=0.80)
total = result["investmentPrincipal"] + result["funMoney"]
assert total == pytest.approx(result["netAmountCad"])
assert result["funMoney"] == pytest.approx(result["netAmountCad"] * 0.20)
def test_cycles_count(self):
result = calculate_us_lottery(100_000_000, cycles=5)
assert len(result["cycles"]) == 5
def test_principal_grows(self):
result = calculate_us_lottery(100_000_000, cycles=4)
for i in range(1, len(result["cycles"])):
assert result["cycles"][i]["principalStart"] >= result["cycles"][i - 1]["principalStart"]
def test_final_principal_positive(self):
result = calculate_us_lottery(500_000_000)
assert result["finalPrincipal"] > 0
def test_daily_income_positive(self):
result = calculate_us_lottery(500_000_000)
assert result["netDailyIncome"] > 0
def test_zero_state_tax(self):
"""Florida / Texas have 0% state tax."""
result = calculate_us_lottery(100_000_000, state_tax_rate=0.0)
assert result["stateTax"] == 0.0
assert result["netAmountUsd"] > calculate_us_lottery(100_000_000, state_tax_rate=0.10)["netAmountUsd"]
# ---------------------------------------------------------------------------
# calculate_canadian_lottery
# ---------------------------------------------------------------------------
class TestCalculateCanadianLottery:
"""Tests for Canadian lottery calculation (tax-free winnings)."""
def test_basic_calculation(self):
result = calculate_canadian_lottery(50_000_000)
assert result["country"] == "Canada"
assert result["originalJackpot"] == 50_000_000
def test_tax_free(self):
result = calculate_canadian_lottery(50_000_000)
assert result["netAmountCad"] == 50_000_000
def test_investment_split(self):
result = calculate_canadian_lottery(50_000_000, invest_percentage=0.90)
assert result["investmentPrincipal"] == pytest.approx(45_000_000)
assert result["funMoney"] == pytest.approx(5_000_000)
def test_cycles_count(self):
result = calculate_canadian_lottery(50_000_000, cycles=3)
assert len(result["cycles"]) == 3
def test_daily_income_positive(self):
result = calculate_canadian_lottery(50_000_000)
assert result["netDailyIncome"] > 0
# ---------------------------------------------------------------------------
# calculate_break_even
# ---------------------------------------------------------------------------
class TestCalculateBreakEven:
"""Tests for the break-even expected-value calculator."""
def test_us_break_even(self):
result = calculate_break_even(odds=292_201_338, ticket_cost=2.0, country="us")
assert result["breakEvenJackpot"] > 0
assert result["breakEvenJackpot"] > 292_201_338 # must be >> odds because of tax
def test_canadian_break_even(self):
result = calculate_break_even(odds=13_983_816, ticket_cost=3.0, country="canadian")
# Canadian take-home fraction = 1.0, so break-even = 3 * 13_983_816
expected = 3.0 * 13_983_816
assert result["breakEvenJackpot"] == pytest.approx(expected)
def test_ev_equals_ticket_cost(self):
result = calculate_break_even(odds=100, ticket_cost=5.0, country="canadian")
# EV at break even = probability * jackpot * 1.0 = 5.0
assert result["expectedValueAtBreakEven"] == pytest.approx(5.0, rel=1e-6)
def test_higher_tax_needs_bigger_jackpot(self):
r1 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.0)
r2 = calculate_break_even(odds=100, ticket_cost=2.0, country="us", state_tax_rate=0.10)
assert r2["breakEvenJackpot"] > r1["breakEvenJackpot"]
# ---------------------------------------------------------------------------
# calculate_annuity
# ---------------------------------------------------------------------------
class TestCalculateAnnuity:
"""Tests for the annuity payout schedule."""
def test_us_annuity_schedule_length(self):
result = calculate_annuity(500_000_000, country="us", years=30)
assert len(result["schedule"]) == 30
def test_canadian_no_tax(self):
result = calculate_annuity(100_000_000, country="canadian", years=10, annual_increase=0.0)
for entry in result["schedule"]:
assert entry["tax"] == 0.0
assert entry["afterTax"] == entry["preTax"]
def test_total_pretax_approximates_jackpot(self):
result = calculate_annuity(100_000_000, country="canadian", years=30, annual_increase=0.05)
assert result["totalPreTax"] == pytest.approx(100_000_000, rel=1e-6)
def test_payments_increase_annually(self):
result = calculate_annuity(100_000_000, years=5, annual_increase=0.05)
for i in range(1, len(result["schedule"])):
assert result["schedule"][i]["preTax"] > result["schedule"][i - 1]["preTax"]
def test_zero_increase(self):
result = calculate_annuity(100_000_000, years=5, annual_increase=0.0)
payments = [e["preTax"] for e in result["schedule"]]
assert all(p == pytest.approx(payments[0]) for p in payments)
# ---------------------------------------------------------------------------
# calculate_group_split
# ---------------------------------------------------------------------------
class TestCalculateGroupSplit:
"""Tests for the group play calculator."""
def test_equal_split(self):
result = calculate_group_split(100_000_000, members=4, country="canadian")
assert len(result["memberResults"]) == 4
for m in result["memberResults"]:
assert m["jackpotShare"] == pytest.approx(25_000_000)
def test_custom_shares(self):
shares = [0.5, 0.3, 0.2]
result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian")
assert result["memberResults"][0]["jackpotShare"] == pytest.approx(50_000_000)
assert result["memberResults"][1]["jackpotShare"] == pytest.approx(30_000_000)
assert result["memberResults"][2]["jackpotShare"] == pytest.approx(20_000_000)
def test_shares_normalize(self):
"""Shares that don't sum to 1.0 should be normalised."""
shares = [2, 2, 1] # sums to 5
result = calculate_group_split(100_000_000, members=3, shares=shares, country="canadian")
assert result["memberResults"][0]["share"] == pytest.approx(0.4)
assert result["memberResults"][2]["share"] == pytest.approx(0.2)
def test_mismatched_shares_falls_back_to_equal(self):
result = calculate_group_split(100_000_000, members=3, shares=[0.5, 0.5], country="canadian")
for m in result["memberResults"]:
assert m["share"] == pytest.approx(1 / 3)
def test_each_member_gets_calculation(self):
result = calculate_group_split(100_000_000, members=2, country="us")
for m in result["memberResults"]:
assert "calculation" in m
assert m["calculation"]["country"] == "US"

111
tests/test_scrapers.py Normal file
View File

@@ -0,0 +1,111 @@
"""Tests for scrapers.py — uses mocks to avoid real HTTP calls."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from scrapers import (
_parse_jackpot_from_lotto_net,
clear_cache,
get_all_jackpots,
scrape_mega_millions,
scrape_powerball,
)
# ---------------------------------------------------------------------------
# HTML parser unit tests
# ---------------------------------------------------------------------------
POWERBALL_HTML = """
<html><body>
<h2>Powerball</h2>
<div>
Next Jackpot
$350 Million
</div>
</body></html>
"""
BILLION_HTML = """
<html><body>
<div>
Next Jackpot
$1.5 Billion
</div>
</body></html>
"""
def test_parse_jackpot_millions():
result = _parse_jackpot_from_lotto_net(POWERBALL_HTML)
assert result == 350_000_000
def test_parse_jackpot_billions():
result = _parse_jackpot_from_lotto_net(BILLION_HTML)
assert result == 1_500_000_000
def test_parse_jackpot_no_match():
result = _parse_jackpot_from_lotto_net("<html><body>No jackpot here</body></html>")
assert result is None
# ---------------------------------------------------------------------------
# Scraper integration tests (mocked HTTP)
# ---------------------------------------------------------------------------
@patch("scrapers.requests.get")
def test_scrape_powerball_success(mock_get):
mock_resp = MagicMock()
mock_resp.text = POWERBALL_HTML
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
result = scrape_powerball()
assert result == 350_000_000
@patch("scrapers.requests.get")
def test_scrape_powerball_failure(mock_get):
mock_get.side_effect = Exception("Network error")
result = scrape_powerball()
assert result is None
@patch("scrapers.requests.get")
def test_scrape_mega_millions_success(mock_get):
html = POWERBALL_HTML.replace("Powerball", "Mega Millions")
mock_resp = MagicMock()
mock_resp.text = html
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
result = scrape_mega_millions()
assert result == 350_000_000
# ---------------------------------------------------------------------------
# Cache tests
# ---------------------------------------------------------------------------
@patch("scrapers.scrape_powerball", return_value=100_000_000)
@patch("scrapers.scrape_mega_millions", return_value=200_000_000)
@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000})
def test_get_all_jackpots_caches(mock_ca, mock_mm, mock_pb):
clear_cache()
r1 = get_all_jackpots()
r2 = get_all_jackpots()
# Second call should use cache — scrapers called only once
assert mock_pb.call_count == 1
assert r1 == r2
@patch("scrapers.scrape_powerball", return_value=100_000_000)
@patch("scrapers.scrape_mega_millions", return_value=200_000_000)
@patch("scrapers.scrape_canadian_lotteries", return_value={"lottoMax": 50_000_000, "lotto649": 10_000_000})
def test_get_all_jackpots_force_refresh(mock_ca, mock_mm, mock_pb):
clear_cache()
get_all_jackpots()
get_all_jackpots(force_refresh=True)
assert mock_pb.call_count == 2