Closes #105 Replace the last_game FK guard in update_season_stats() with an atomic INSERT into a new processed_game ledger table. The old guard only blocked same-game immediate replay; it was silently bypassed if game G+1 was processed first (last_game already overwritten). The ledger is keyed on game_id so any re-delivery — including out-of-order — is caught reliably. Changes: - app/db_engine.py: add ProcessedGame model (game FK PK + processed_at) - app/services/season_stats.py: replace last_game check with ProcessedGame.get_or_create(); import ProcessedGame; update docstrings - migrations/2026-03-18_add_processed_game.sql: CREATE TABLE IF NOT EXISTS processed_game with FK to stratgame ON DELETE CASCADE - tests/conftest.py: add ProcessedGame to imports and _TEST_MODELS list - tests/test_season_stats_update.py: add test_out_of_order_replay_prevented; update test_double_count_prevention docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
4.7 KiB
Python
176 lines
4.7 KiB
Python
"""
|
|
Shared test fixtures for the Paper Dynasty database test suite.
|
|
|
|
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
|
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
|
|
|
All models are bound to the in-memory database before table creation
|
|
so that no connection to the real storage/pd_master.db occurs during
|
|
tests.
|
|
"""
|
|
|
|
import os
|
|
import pytest
|
|
from peewee import SqliteDatabase
|
|
|
|
# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION
|
|
# flag is True. This prevents db_engine.py from calling create_tables()
|
|
# against the real storage/pd_master.db during import — those calls would
|
|
# fail if indexes already exist and would also contaminate the dev database.
|
|
# The PooledPostgresqlDatabase object is created but never actually connects
|
|
# because our fixture rebinds all models to an in-memory SQLite db before
|
|
# any query is executed.
|
|
os.environ["DATABASE_TYPE"] = "postgresql"
|
|
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
|
# without raising a configuration error (it will not actually be used).
|
|
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
|
|
|
from app.db_engine import (
|
|
Rarity,
|
|
Event,
|
|
Cardset,
|
|
MlbPlayer,
|
|
Player,
|
|
Team,
|
|
PackType,
|
|
Pack,
|
|
Card,
|
|
Roster,
|
|
RosterSlot,
|
|
StratGame,
|
|
StratPlay,
|
|
Decision,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
ProcessedGame,
|
|
EvolutionTrack,
|
|
EvolutionCardState,
|
|
EvolutionTierBoost,
|
|
EvolutionCosmetic,
|
|
ScoutOpportunity,
|
|
ScoutClaim,
|
|
)
|
|
|
|
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
|
|
|
# All models in dependency order (parents before children) so that
|
|
# create_tables and drop_tables work without FK violations.
|
|
_TEST_MODELS = [
|
|
Rarity,
|
|
Event,
|
|
Cardset,
|
|
MlbPlayer,
|
|
Player,
|
|
Team,
|
|
PackType,
|
|
Pack,
|
|
Card,
|
|
Roster,
|
|
RosterSlot,
|
|
StratGame,
|
|
StratPlay,
|
|
Decision,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
ProcessedGame,
|
|
ScoutOpportunity,
|
|
ScoutClaim,
|
|
EvolutionTrack,
|
|
EvolutionCardState,
|
|
EvolutionTierBoost,
|
|
EvolutionCosmetic,
|
|
]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_test_db():
|
|
"""Bind all models to in-memory SQLite and create tables.
|
|
|
|
The fixture is autouse so every test automatically gets a fresh,
|
|
isolated database schema without needing to request it explicitly.
|
|
Tables are dropped in reverse dependency order after each test to
|
|
keep the teardown clean and to catch any accidental FK reference
|
|
direction bugs early.
|
|
"""
|
|
_test_db.bind(_TEST_MODELS)
|
|
_test_db.connect()
|
|
_test_db.create_tables(_TEST_MODELS)
|
|
yield _test_db
|
|
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
|
_test_db.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal shared fixtures — create just enough data for FK dependencies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def rarity():
|
|
"""A single Common rarity row used as FK seed for Player rows."""
|
|
return Rarity.create(value=1, name="Common", color="#ffffff")
|
|
|
|
|
|
@pytest.fixture
|
|
def player(rarity):
|
|
"""A minimal Player row with all required (non-nullable) columns filled.
|
|
|
|
Player.p_name is the real column name (not 'name'). All FK and
|
|
non-nullable varchar fields are provided so SQLite's NOT NULL
|
|
constraints are satisfied even with foreign_keys=ON.
|
|
"""
|
|
cardset = Cardset.create(
|
|
name="Test Set",
|
|
description="Test cardset",
|
|
total_cards=100,
|
|
)
|
|
return Player.create(
|
|
p_name="Test Player",
|
|
rarity=rarity,
|
|
cardset=cardset,
|
|
set_num=1,
|
|
pos_1="1B",
|
|
image="https://example.com/image.png",
|
|
mlbclub="TST",
|
|
franchise="TST",
|
|
description="A test player",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def team():
|
|
"""A minimal Team row.
|
|
|
|
Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/
|
|
collection_value — not the 'name'/'user_id' shorthand described in
|
|
the spec, which referred to the real underlying columns by
|
|
simplified names.
|
|
"""
|
|
return Team.create(
|
|
abbrev="TST",
|
|
sname="Test",
|
|
lname="Test Team",
|
|
gmid=100000001,
|
|
gmname="testuser",
|
|
gsheet="https://docs.google.com/spreadsheets/test",
|
|
wallet=500,
|
|
team_value=1000,
|
|
collection_value=1000,
|
|
season=11,
|
|
is_ai=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def track():
|
|
"""A minimal EvolutionTrack for batter cards."""
|
|
return EvolutionTrack.create(
|
|
name="Batter Track",
|
|
card_type="batter",
|
|
formula="pa + tb * 2",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
)
|