From 0789cb89eaf7d34146eb2da292b31c0846e1b836 Mon Sep 17 00:00:00 2001 From: Petr Baudis Date: Wed, 23 Apr 2025 16:45:06 +0200 Subject: [PATCH] refactor: Convert currency tests to pytest and run in Docker --- brmbar3/Dockerfile | 1 + brmbar3/docker-entrypoint.sh | 2 +- brmbar3/test--currency-rates.py | 75 -------------------- brmbar3/tests/test_currency_rates.py | 102 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 76 deletions(-) delete mode 100644 brmbar3/test--currency-rates.py create mode 100644 brmbar3/tests/test_currency_rates.py diff --git a/brmbar3/Dockerfile b/brmbar3/Dockerfile index 042cbb5..f54ff86 100644 --- a/brmbar3/Dockerfile +++ b/brmbar3/Dockerfile @@ -4,6 +4,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y \ python3 \ python3-psycopg2 \ + python3-pytest \ postgresql \ postgresql-client \ sudo \ diff --git a/brmbar3/docker-entrypoint.sh b/brmbar3/docker-entrypoint.sh index 32bc62f..40ed1e8 100755 --- a/brmbar3/docker-entrypoint.sh +++ b/brmbar3/docker-entrypoint.sh @@ -46,7 +46,7 @@ if [ "$1" == "test" ]; then # Run test (as brmuser) echo "Running python test..." - python3 /app/test--currency-rates.py + python3 -m pytest # Run pytest as a module else # Run the specified command exec "$@" diff --git a/brmbar3/test--currency-rates.py b/brmbar3/test--currency-rates.py deleted file mode 100644 index 8c5d180..0000000 --- a/brmbar3/test--currency-rates.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/python3 - -import sys -import subprocess - -#from brmbar import Database -#from brmbar import Currency - -from contextlib import closing -import psycopg2 -from brmbar.Database import Database -from brmbar.Currency import Currency -import math - -#import brmbar - - - -def approx_equal(a, b, tol=1e-6): - """Check if two (buy, sell) rate tuples are approximately equal.""" - return ( - isinstance(a, tuple) and isinstance(b, tuple) and - math.isclose(a[0], b[0], abs_tol=tol) and - math.isclose(a[1], b[1], abs_tol=tol) - ) - -def compare_exceptions(e1, e2): - """Compare exception types and messages.""" - return type(e1) == type(e2) and str(e1) == str(e2) - -def main(): - db = Database("dbname=brmbar") - - # Get all currencies - with closing(db.db_conn.cursor()) as cur: - cur.execute("SELECT id, name FROM currencies") - currencies = cur.fetchall() - - # Build Currency objects - currency_objs = [Currency(db, id, name) for id, name in currencies] - - # Test all currency pairs - for c1 in currency_objs: - for c2 in currency_objs: - #if c1.id == c2.id: - # continue - - try: - rates1 = c1.rates(c2) - exc1 = None - except (RuntimeError, NameError) as e1: - rates1 = None - exc1 = e1 - - try: - rates2 = c1.rates2(c2) - exc2 = None - except (RuntimeError, NameError) as e2: - rates2 = None - exc2 = e2 - - if exc1 or exc2: - if not compare_exceptions(exc1, exc2): - print(f"[EXCEPTION DIFFERENCE] {c1.name} -> {c2.name}") - print(f" rates() exception: {type(exc1).__name__}: {exc1}") - print(f" rates2() exception: {type(exc2).__name__}: {exc2}") - elif not approx_equal(rates1, rates2): - print(f"[VALUE DIFFERENCE] {c1.name} -> {c2.name}") - print(f" rates(): {rates1}") - print(f" rates2(): {rates2}") - else: - print(f"{c1.name} -> {c2.name} {rates1} {rates2} OK") - -if __name__ == "__main__": - main() diff --git a/brmbar3/tests/test_currency_rates.py b/brmbar3/tests/test_currency_rates.py new file mode 100644 index 0000000..d781e35 --- /dev/null +++ b/brmbar3/tests/test_currency_rates.py @@ -0,0 +1,102 @@ +# tests/test_currency_rates.py +import pytest +from contextlib import closing +import math +from decimal import Decimal + +# Assuming brmbar modules are importable via PYTHONPATH or package structure +from brmbar.Database import Database +from brmbar.Currency import Currency + +# --- Fixtures --- + +@pytest.fixture(scope="module") +def db_connection(): + """Provides a database connection for the test module.""" + # Ensure dbname is correct for your test environment + # This connects using the default user/db setup expected by the entrypoint + db = Database("dbname=brmbar") + yield db + # Explicitly close the connection if Database doesn't handle it + if hasattr(db, 'db_conn') and db.db_conn and not db.db_conn.closed: + db.db_conn.close() + +@pytest.fixture(scope="module") +def all_currencies(db_connection): + """Fetches all currency objects from the database.""" + # Ensure the connection is open before using it + if not db_connection or not hasattr(db_connection, 'db_conn') or db_connection.db_conn.closed: + pytest.skip("Database connection not available for fetching currencies") # Skip if DB setup failed + + currencies_data = [] + try: + with closing(db_connection.db_conn.cursor()) as cur: + # Added ORDER BY for consistent test order and reliable pairing + cur.execute("SELECT id, name FROM currencies ORDER BY id") + currencies_data = cur.fetchall() + except Exception as e: + pytest.fail(f"Failed to fetch currencies from DB: {e}") + + if not currencies_data: + pytest.skip("No currencies found in the database to test") + + # Ensure db_connection is passed correctly to Currency constructor + return [Currency(db_connection, id, name) for id, name in currencies_data] + + +# --- Test Function --- + +# Helper to run a rate function and capture result or exception +def get_rate_or_exception(currency_from, currency_to, method_name): + method = getattr(currency_from, method_name) + try: + # Ensure currency_to is passed to the method + return method(currency_to), None + except (RuntimeError, NameError) as e: # Add other expected exceptions if any + return None, e + except Exception as e: # Catch unexpected exceptions during rate calculation + pytest.fail(f"Unexpected exception in {method_name} for {currency_from.name}->{currency_to.name}: {type(e).__name__}: {e}") + + +def test_rate_comparison(all_currencies): + """Compares the results of rates() and rates2() for all currency pairs.""" + + if not all_currencies: + pytest.skip("Skipping test as no currencies were fetched.") + + # Iterate through all pairs of currencies + for c1 in all_currencies: + for c2 in all_currencies: + # Ensure c1 and c2 are actual Currency objects (good practice, though should be true) + assert isinstance(c1, Currency), f"Param c1 is not a Currency object: {c1}" + assert isinstance(c2, Currency), f"Param c2 is not a Currency object: {c2}" + + rates1, exc1 = get_rate_or_exception(c1, c2, 'rates') + rates2, exc2 = get_rate_or_exception(c1, c2, 'rates2') + + if exc1 or exc2: + # If one raised an exception, the other must raise the *same* type and message + assert exc1 is not None and exc2 is not None, \ + f"Mismatch in exceptions for {c1.name}->{c2.name}. rates(): {exc1}, rates2(): {exc2}" + assert type(exc1) == type(exc2), \ + f"Exception type mismatch for {c1.name}->{c2.name}: {type(exc1).__name__} vs {type(exc2).__name__}" + assert str(exc1) == str(exc2), \ + f"Exception message mismatch for {c1.name}->{c2.name}: '{exc1}' vs '{exc2}'" + # If exceptions match, the test passes for this pair + else: + # If neither raised an exception, compare rates using pytest.approx + assert rates1 is not None and rates2 is not None, \ + f"Expected rates but got None (and no exception) for {c1.name}->{c2.name}" + + # Check if rates are tuples of two numbers (buy, sell) + assert isinstance(rates1, tuple) and len(rates1) == 2, f"rates() result not a tuple(buy, sell) for {c1.name}->{c2.name}: {rates1}" + assert isinstance(rates2, tuple) and len(rates2) == 2, f"rates2() result not a tuple(buy, sell) for {c1.name}->{c2.name}: {rates2}" + assert all(isinstance(r, (int, float, Decimal)) for r in rates1), f"rates() values not numeric for {c1.name}->{c2.name}: {rates1}" + assert all(isinstance(r, (int, float, Decimal)) for r in rates2), f"rates2() values not numeric for {c1.name}->{c2.name}: {rates2}" + + # Compare buy and sell rates approximately + # Use rel= or abs= if default tolerance is not suitable + assert rates1[0] == pytest.approx(rates2[0]), \ + f"Buy rate mismatch for {c1.name}->{c2.name}: {rates1[0]} vs {rates2[0]}" + assert rates1[1] == pytest.approx(rates2[1]), \ + f"Sell rate mismatch for {c1.name}->{c2.name}: {rates1[1]} vs {rates2[1]}" \ No newline at end of file