feat: SQL migration for evolution tables and variant/image_url columns (#69) #84
179
migrations/2026-03-12_add_evolution_tables.sql
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
-- Migration: Add evolution tables and variant/image_url columns
|
||||||
|
-- Date: 2026-03-12
|
||||||
|
-- Issue: #69 (WP-04)
|
||||||
|
-- Dependencies: WP-01 (evolution models), WP-02 (PlayerSeasonStats model)
|
||||||
|
-- Purpose: Create player_season_stats, evolution_track, evolution_card_state,
|
||||||
|
-- evolution_tier_boost, evolution_cosmetic tables.
|
||||||
|
-- Add card.variant, battingcard.image_url, pitchingcard.image_url columns.
|
||||||
|
--
|
||||||
|
-- This migration is idempotent: all CREATE TABLE use IF NOT EXISTS,
|
||||||
|
-- ADD COLUMN uses IF NOT EXISTS, and all CREATE INDEX use IF NOT EXISTS.
|
||||||
|
--
|
||||||
|
-- Run on dev first, verify with:
|
||||||
|
-- SELECT table_name FROM information_schema.tables
|
||||||
|
-- WHERE table_schema = 'public'
|
||||||
|
-- AND table_name IN (
|
||||||
|
-- 'player_season_stats','evolution_track','evolution_card_state',
|
||||||
|
-- 'evolution_tier_boost','evolution_cosmetic'
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- Rollback: See DROP statements at bottom of file
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FORWARD MIGRATION
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 1. player_season_stats
|
||||||
|
|
|||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS player_season_stats (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||||
|
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||||
|
season INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- Batting stats
|
||||||
|
games_batting INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pa INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ab INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hr INTEGER NOT NULL DEFAULT 0,
|
||||||
|
doubles INTEGER NOT NULL DEFAULT 0,
|
||||||
|
triples INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hbp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
so INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rbi INTEGER NOT NULL DEFAULT 0,
|
||||||
|
runs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sb INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Pitching stats
|
||||||
|
games_pitching INTEGER NOT NULL DEFAULT 0,
|
||||||
|
outs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cal
commented
PRD §6.1 specifies PRD §6.1 specifies `innings NUMERIC(5,1) NOT NULL DEFAULT 0` here. Using `outs INTEGER` is a lossless alternative (1 IP = 3 outs) but it is an undocumented divergence from the locked spec. Please confirm this matches the WP-02 Peewee model field name and add a comment explaining the choice (e.g. `-- stored as outs to avoid NUMERIC; 1 IP = 3 outs`).
|
|||||||
|
k INTEGER NOT NULL DEFAULT 0, -- pitcher Ks (named k to avoid collision with batting so)
|
||||||
|
bb_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
saves INTEGER NOT NULL DEFAULT 0,
|
||||||
|
holds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blown_saves INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Meta
|
||||||
|
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||||
|
last_updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS player_season_stats_player_team_season_uniq
|
||||||
|
ON player_season_stats (player_id, team_id, season);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS player_season_stats_team_season_idx
|
||||||
|
ON player_season_stats (team_id, season);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx
|
||||||
|
ON player_season_stats (player_id, season);
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 2. evolution_track
|
||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS evolution_track (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
card_type VARCHAR(255) NOT NULL UNIQUE, -- batter / sp / rp
|
||||||
|
formula VARCHAR(255) NOT NULL,
|
||||||
|
cal
commented
The ORM defines The ORM defines `name = CharField(unique=True)` on `EvolutionTrack`, but the migration only defines UNIQUE on `card_type`. A UNIQUE constraint on `name` is missing.
|
|||||||
|
t1_threshold INTEGER NOT NULL,
|
||||||
|
t2_threshold INTEGER NOT NULL,
|
||||||
|
t3_threshold INTEGER NOT NULL,
|
||||||
|
t4_threshold INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 3. evolution_card_state
|
||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS evolution_card_state (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||||
|
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||||
|
cal
commented
PRD §6.1 defines PRD §6.1 defines `progress_since TIMESTAMP NOT NULL` on `evolution_card_state` — the earliest `card.created_at` among all card instances of this player on this team. This scopes which game records count toward evolution progress. It is absent here. Intentionally deferred?
|
|||||||
|
track_id INTEGER NOT NULL REFERENCES evolution_track(id),
|
||||||
|
current_tier INTEGER NOT NULL DEFAULT 0, -- valid range: 0-4
|
||||||
|
current_value DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
fully_evolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_evaluated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq
|
||||||
|
cal
commented
Stub is missing all real columns. The Stub is missing all real columns. The `EvolutionTierBoost` ORM model has: `track` (FK to evolution_track), `tier` INTEGER, `boost_type` VARCHAR, `boost_target` VARCHAR, `boost_value` DOUBLE PRECISION, plus a unique index on (track, tier, boost_type, boost_target). The `card_state_id` FK here does not exist in the ORM at all.
|
|||||||
|
ON evolution_card_state (player_id, team_id);
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 4. evolution_tier_boost (Phase 2 stub)
|
||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS evolution_tier_boost (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
card_state_id INTEGER NOT NULL REFERENCES evolution_card_state(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
cal
commented
Stub is missing all real columns and has the wrong FK. The Stub is missing all real columns and has the wrong FK. The `EvolutionCosmetic` ORM model is a standalone lookup table with: `name` VARCHAR UNIQUE, `tier_required` INTEGER, `cosmetic_type` VARCHAR, `css_class` VARCHAR NULL, `asset_url` VARCHAR NULL. There is no `card_state_id` reference in the ORM model.
|
|||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 5. evolution_cosmetic (Phase 2 stub)
|
||||||
|
-- --------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS evolution_cosmetic (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
card_state_id INTEGER NOT NULL REFERENCES evolution_card_state(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
cal
commented
The The `Card` ORM model on this branch does not have a `variant` field. If this column is intentional, the `Card` model needs to be updated. If not, this ALTER should be removed — `variant` already exists on `battingcard` and `pitchingcard` as a non-null column.
|
|||||||
|
-- --------------------------------------------
|
||||||
|
-- 6. Add card.variant column
|
||||||
|
-- --------------------------------------------
|
||||||
|
ALTER TABLE card
|
||||||
|
ADD COLUMN IF NOT EXISTS variant INTEGER NULL DEFAULT NULL;
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 7. Add battingcard.image_url column
|
||||||
|
-- --------------------------------------------
|
||||||
|
ALTER TABLE battingcard
|
||||||
|
cal
commented
PRD §6.2 specifies PRD §6.2 specifies `image_format VARCHAR(10) NULL DEFAULT 'png'` alongside `image_url` on both `battingcard` and `pitchingcard`. This column distinguishes static PNG from animated APNG cosmetics and is part of the image lookup logic described in the PRD. Is this intentionally deferred? If so, please note the tracking issue.
|
|||||||
|
ADD COLUMN IF NOT EXISTS image_url VARCHAR(500) NULL;
|
||||||
|
|
||||||
|
-- --------------------------------------------
|
||||||
|
-- 8. Add pitchingcard.image_url column
|
||||||
|
-- --------------------------------------------
|
||||||
|
ALTER TABLE pitchingcard
|
||||||
|
ADD COLUMN IF NOT EXISTS image_url VARCHAR(500) NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VERIFICATION QUERIES
|
||||||
|
-- ============================================
|
||||||
|
-- SELECT table_name FROM information_schema.tables
|
||||||
|
-- WHERE table_schema = 'public'
|
||||||
|
-- AND table_name IN (
|
||||||
|
-- 'player_season_stats','evolution_track','evolution_card_state',
|
||||||
|
-- 'evolution_tier_boost','evolution_cosmetic'
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- SELECT indexname, tablename FROM pg_indexes
|
||||||
|
-- WHERE tablename IN (
|
||||||
|
-- 'player_season_stats','evolution_card_state'
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_name = 'card' AND column_name = 'variant';
|
||||||
|
--
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_name IN ('battingcard','pitchingcard')
|
||||||
|
-- AND column_name = 'image_url';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ROLLBACK (if needed)
|
||||||
|
-- ============================================
|
||||||
|
-- ALTER TABLE pitchingcard DROP COLUMN IF EXISTS image_url;
|
||||||
|
-- ALTER TABLE battingcard DROP COLUMN IF EXISTS image_url;
|
||||||
|
-- ALTER TABLE card DROP COLUMN IF EXISTS variant;
|
||||||
|
-- DROP TABLE IF EXISTS evolution_cosmetic CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS evolution_track CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS player_season_stats CASCADE;
|
||||||
0
tests/__init__.py
Normal file
48
tests/conftest.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Pytest configuration for the paper-dynasty-database test suite.
|
||||||
|
|
||||||
|
Sets DATABASE_TYPE=postgresql before any app module is imported so that
|
||||||
|
db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the
|
||||||
|
production SQLite file during test collection. Each test module is
|
||||||
|
responsible for binding models to its own in-memory database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def pg_conn():
|
||||||
|
"""Live PostgreSQL connection for integration tests.
|
||||||
|
|
||||||
|
Requires POSTGRES_HOST to be set; runs the migration before yielding the
|
||||||
|
connection so that all integration tests start from a migrated schema.
|
||||||
|
The connection is rolled back and closed at session teardown so that the
|
||||||
|
migration is not persisted to the dev database between test runs.
|
||||||
|
"""
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=os.environ["POSTGRES_HOST"],
|
||||||
|
dbname=os.environ.get("POSTGRES_DB", "pd_master"),
|
||||||
|
user=os.environ.get("POSTGRES_USER", "pd_admin"),
|
||||||
|
password=os.environ["POSTGRES_PASSWORD"],
|
||||||
|
port=int(os.environ.get("POSTGRES_PORT", "5432")),
|
||||||
|
)
|
||||||
|
conn.autocommit = False
|
||||||
|
migration_path = os.path.join(
|
||||||
|
cal
commented
The migration SQL contains The docstring promise — "The connection is rolled back and closed at session teardown so that the migration is not persisted to the dev database between test runs" — is incorrect as written. Fix options:
The migration SQL contains `BEGIN; ... COMMIT;`. When this fixture calls `conn.cursor().execute(fh.read())` and then `conn.commit()`, the `COMMIT` inside the SQL fires during execute and permanently durably commits the DDL. The `conn.rollback()` at teardown has no effect on the already-committed schema changes.
The docstring promise — *"The connection is rolled back and closed at session teardown so that the migration is not persisted to the dev database between test runs"* — is incorrect as written.
Fix options:
- Strip `BEGIN;` / `COMMIT;` from the SQL before executing, so the transaction is under the fixture's control.
- Or accept permanent migration and update the docstring to reflect that the integration test is run-once against dev (which may be the intended use).
|
|||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"migrations",
|
||||||
|
"2026-03-12_add_evolution_tables.sql",
|
||||||
|
)
|
||||||
|
with open(migration_path) as fh:
|
||||||
|
conn.cursor().execute(fh.read())
|
||||||
|
conn.commit()
|
||||||
|
yield conn
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
423
tests/test_evolution_migration.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
"""Tests for the evolution tables SQL migration (WP-04).
|
||||||
|
|
||||||
|
Unit tests read the SQL migration file and verify that all required DDL
|
||||||
|
statements are present — no database connection needed.
|
||||||
|
|
||||||
|
Integration tests (marked with pytest.mark.integration) execute the
|
||||||
|
migration against a live PostgreSQL instance and verify structure via
|
||||||
|
information_schema and pg_indexes. They are skipped automatically when
|
||||||
|
POSTGRES_HOST is not set, and are designed to be idempotent so they can
|
||||||
|
be run against the dev database without damage.
|
||||||
|
|
||||||
|
To run integration tests locally:
|
||||||
|
POSTGRES_HOST=localhost POSTGRES_USER=pd_admin POSTGRES_PASSWORD=... \\
|
||||||
|
pytest tests/test_evolution_migration.py -m integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MIGRATION_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"migrations",
|
||||||
|
"2026-03-12_add_evolution_tables.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sql():
|
||||||
|
"""Return the migration file text, lowercased and with SQL comments stripped.
|
||||||
|
|
||||||
|
Single-line comments (-- ...) are removed before pattern matching so that
|
||||||
|
the verification regexes do not accidentally match against comment text.
|
||||||
|
"""
|
||||||
|
with open(MIGRATION_PATH) as fh:
|
||||||
|
content = fh.read()
|
||||||
|
content = re.sub(r"--[^\n]*", "", content)
|
||||||
|
return content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests — SQL file content, no DB required
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_file_exists():
|
||||||
|
"""The migration file must exist at the expected path."""
|
||||||
|
assert os.path.isfile(MIGRATION_PATH), f"Migration file not found: {MIGRATION_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_wrapped_in_transaction():
|
||||||
|
"""Migration must be wrapped in BEGIN / COMMIT for atomicity."""
|
||||||
|
sql = _load_sql()
|
||||||
|
assert "begin;" in sql
|
||||||
|
assert "commit;" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_five_tables_created():
|
||||||
|
"""Each of the five new tables must have a CREATE TABLE IF NOT EXISTS statement."""
|
||||||
|
sql = _load_sql()
|
||||||
|
expected = [
|
||||||
|
"player_season_stats",
|
||||||
|
"evolution_track",
|
||||||
|
"evolution_card_state",
|
||||||
|
"evolution_tier_boost",
|
||||||
|
"evolution_cosmetic",
|
||||||
|
]
|
||||||
|
for table in expected:
|
||||||
|
pattern = rf"create table if not exists {table}"
|
||||||
|
assert re.search(
|
||||||
|
pattern, sql
|
||||||
|
), f"Missing CREATE TABLE IF NOT EXISTS for {table}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_season_stats_columns():
|
||||||
|
"""player_season_stats must contain all required batting, pitching and meta columns."""
|
||||||
|
sql = _load_sql()
|
||||||
|
required_columns = [
|
||||||
|
"player_id",
|
||||||
|
"team_id",
|
||||||
|
"season",
|
||||||
|
"games_batting",
|
||||||
|
"pa",
|
||||||
|
"ab",
|
||||||
|
"hits",
|
||||||
|
"hr",
|
||||||
|
"doubles",
|
||||||
|
"triples",
|
||||||
|
"bb",
|
||||||
|
"hbp",
|
||||||
|
"so",
|
||||||
|
"rbi",
|
||||||
|
"runs",
|
||||||
|
"sb",
|
||||||
|
"cs",
|
||||||
|
"games_pitching",
|
||||||
|
"outs",
|
||||||
|
r"\bk\b", # pitcher Ks — match as whole word to avoid false positives
|
||||||
|
"bb_allowed",
|
||||||
|
"hits_allowed",
|
||||||
|
"hr_allowed",
|
||||||
|
"wins",
|
||||||
|
"losses",
|
||||||
|
"saves",
|
||||||
|
"holds",
|
||||||
|
"blown_saves",
|
||||||
|
"last_game_id",
|
||||||
|
"last_updated_at",
|
||||||
|
]
|
||||||
|
# Extract just the player_season_stats block for targeted matching
|
||||||
|
match = re.search(
|
||||||
|
r"create table if not exists player_season_stats\s*\((.+?)\);",
|
||||||
|
sql,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "Could not locate player_season_stats table body"
|
||||||
|
block = match.group(1)
|
||||||
|
for col in required_columns:
|
||||||
|
assert re.search(
|
||||||
|
col, block
|
||||||
|
), f"Missing column pattern '{col}' in player_season_stats"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_track_columns():
|
||||||
|
"""evolution_track must have name, card_type, formula, and t1–t4 threshold columns."""
|
||||||
|
sql = _load_sql()
|
||||||
|
match = re.search(
|
||||||
|
r"create table if not exists evolution_track\s*\((.+?)\);",
|
||||||
|
sql,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "Could not locate evolution_track table body"
|
||||||
|
block = match.group(1)
|
||||||
|
for col in [
|
||||||
|
"name",
|
||||||
|
"card_type",
|
||||||
|
"formula",
|
||||||
|
"t1_threshold",
|
||||||
|
"t2_threshold",
|
||||||
|
"t3_threshold",
|
||||||
|
"t4_threshold",
|
||||||
|
]:
|
||||||
|
assert col in block, f"Missing column '{col}' in evolution_track"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_card_state_columns():
|
||||||
|
"""evolution_card_state must have player_id, team_id, track_id, tier, value, and flags."""
|
||||||
|
sql = _load_sql()
|
||||||
|
match = re.search(
|
||||||
|
r"create table if not exists evolution_card_state\s*\((.+?)\);",
|
||||||
|
sql,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "Could not locate evolution_card_state table body"
|
||||||
|
block = match.group(1)
|
||||||
|
for col in [
|
||||||
|
"player_id",
|
||||||
|
"team_id",
|
||||||
|
"track_id",
|
||||||
|
"current_tier",
|
||||||
|
"current_value",
|
||||||
|
"fully_evolved",
|
||||||
|
"last_evaluated_at",
|
||||||
|
]:
|
||||||
|
assert col in block, f"Missing column '{col}' in evolution_card_state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase2_stubs_have_card_state_fk():
|
||||||
|
"""evolution_tier_boost and evolution_cosmetic must reference evolution_card_state."""
|
||||||
|
sql = _load_sql()
|
||||||
|
for table in ["evolution_tier_boost", "evolution_cosmetic"]:
|
||||||
|
match = re.search(
|
||||||
|
rf"create table if not exists {table}\s*\((.+?)\);",
|
||||||
|
sql,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, f"Could not locate {table} table body"
|
||||||
|
block = match.group(1)
|
||||||
|
assert "card_state_id" in block, f"Missing card_state_id FK in {table}"
|
||||||
|
assert (
|
||||||
|
"evolution_card_state" in block
|
||||||
|
), f"Missing FK reference to evolution_card_state in {table}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_indexes_use_if_not_exists():
|
||||||
|
"""All CREATE INDEX statements must use IF NOT EXISTS for idempotency."""
|
||||||
|
sql = _load_sql()
|
||||||
|
raw_indexes = re.findall(r"create(?:\s+unique)?\s+index\s+(?!if not exists)", sql)
|
||||||
|
assert not raw_indexes, (
|
||||||
|
f"Found CREATE INDEX without IF NOT EXISTS — migration is not idempotent. "
|
||||||
|
f"Matches: {raw_indexes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_indexes_present():
|
||||||
|
"""The migration must create all required indexes."""
|
||||||
|
sql = _load_sql()
|
||||||
|
expected_indexes = [
|
||||||
|
"player_season_stats_player_team_season_uniq",
|
||||||
|
"player_season_stats_team_season_idx",
|
||||||
|
"player_season_stats_player_season_idx",
|
||||||
|
"evolution_card_state_player_team_uniq",
|
||||||
|
]
|
||||||
|
for idx in expected_indexes:
|
||||||
|
assert idx in sql, f"Missing index '{idx}' in migration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_season_stats_unique_index_covers_correct_columns():
|
||||||
|
"""The UNIQUE index on player_season_stats must cover (player_id, team_id, season)."""
|
||||||
|
sql = _load_sql()
|
||||||
|
match = re.search(
|
||||||
|
r"player_season_stats_player_team_season_uniq[^\n]*\n\s+on player_season_stats\s*\(([^)]+)\)",
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
assert match, "Could not locate UNIQUE index definition for player_season_stats"
|
||||||
|
cols = match.group(1)
|
||||||
|
for col in ["player_id", "team_id", "season"]:
|
||||||
|
assert (
|
||||||
|
col in cols
|
||||||
|
), f"Column '{col}' missing from UNIQUE index on player_season_stats"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_card_state_unique_index_covers_correct_columns():
|
||||||
|
"""The UNIQUE index on evolution_card_state must cover (player_id, team_id)."""
|
||||||
|
sql = _load_sql()
|
||||||
|
match = re.search(
|
||||||
|
r"evolution_card_state_player_team_uniq[^\n]*\n\s+on evolution_card_state\s*\(([^)]+)\)",
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
assert match, "Could not locate UNIQUE index definition for evolution_card_state"
|
||||||
|
cols = match.group(1)
|
||||||
|
for col in ["player_id", "team_id"]:
|
||||||
|
assert (
|
||||||
|
col in cols
|
||||||
|
), f"Column '{col}' missing from UNIQUE index on evolution_card_state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_variant_column_added():
|
||||||
|
"""card.variant must be added as INTEGER NULL DEFAULT NULL."""
|
||||||
|
sql = _load_sql()
|
||||||
|
assert "alter table card" in sql
|
||||||
|
assert "add column if not exists variant" in sql
|
||||||
|
match = re.search(
|
||||||
|
r"add column if not exists variant\s+(\w+)\s+null\s+default null", sql
|
||||||
|
)
|
||||||
|
assert match, "card.variant must be INTEGER NULL DEFAULT NULL"
|
||||||
|
assert (
|
||||||
|
match.group(1) == "integer"
|
||||||
|
), f"card.variant type must be INTEGER, got {match.group(1)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_battingcard_image_url_column_added():
|
||||||
|
"""battingcard.image_url must be added as VARCHAR(500) NULL."""
|
||||||
|
sql = _load_sql()
|
||||||
|
assert "alter table battingcard" in sql
|
||||||
|
match = re.search(
|
||||||
|
r"alter table battingcard\s+add column if not exists image_url\s+varchar\(500\)\s+null",
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
assert match, "battingcard.image_url must be VARCHAR(500) NULL with IF NOT EXISTS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitchingcard_image_url_column_added():
|
||||||
|
"""pitchingcard.image_url must be added as VARCHAR(500) NULL."""
|
||||||
|
sql = _load_sql()
|
||||||
|
assert "alter table pitchingcard" in sql
|
||||||
|
match = re.search(
|
||||||
|
r"alter table pitchingcard\s+add column if not exists image_url\s+varchar\(500\)\s+null",
|
||||||
|
sql,
|
||||||
|
)
|
||||||
|
assert match, "pitchingcard.image_url must be VARCHAR(500) NULL with IF NOT EXISTS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_column_uses_if_not_exists():
|
||||||
|
"""All ALTER TABLE ADD COLUMN statements must use IF NOT EXISTS for idempotency."""
|
||||||
|
sql = _load_sql()
|
||||||
|
# Find any ADD COLUMN that is NOT followed by IF NOT EXISTS
|
||||||
|
bad_adds = re.findall(r"add column(?!\s+if not exists)", sql)
|
||||||
|
assert not bad_adds, (
|
||||||
|
f"Found ADD COLUMN without IF NOT EXISTS — migration is not idempotent. "
|
||||||
|
f"Count: {len(bad_adds)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_section_present():
|
||||||
|
"""Migration must include rollback DROP statements for documentation."""
|
||||||
|
with open(MIGRATION_PATH) as fh:
|
||||||
|
raw = fh.read().lower()
|
||||||
|
assert "rollback" in raw
|
||||||
|
assert "drop table if exists" in raw
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests — require live PostgreSQL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_pg_available = bool(os.environ.get("POSTGRES_HOST"))
|
||||||
|
|
||||||
|
pytestmark_integration = pytest.mark.skipif(
|
||||||
|
not _pg_available,
|
||||||
|
reason="POSTGRES_HOST not set — skipping integration tests",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_fresh_migration_creates_all_tables(pg_conn):
|
||||||
|
"""Running the migration on a clean schema creates all 5 expected tables."""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN (
|
||||||
|
'player_season_stats', 'evolution_track', 'evolution_card_state',
|
||||||
|
'evolution_tier_boost', 'evolution_cosmetic'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
found = {row[0] for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
expected = {
|
||||||
|
"player_season_stats",
|
||||||
|
"evolution_track",
|
||||||
|
"evolution_card_state",
|
||||||
|
"evolution_tier_boost",
|
||||||
|
"evolution_cosmetic",
|
||||||
|
}
|
||||||
|
assert found == expected, f"Missing tables: {expected - found}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_idempotent_rerun(pg_conn):
|
||||||
|
"""Running the migration a second time must not raise any errors."""
|
||||||
|
with open(MIGRATION_PATH) as fh:
|
||||||
|
sql = fh.read()
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute(sql)
|
||||||
|
pg_conn.commit()
|
||||||
|
cur.close()
|
||||||
|
# If we reach here, the second run succeeded
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_required_indexes_exist(pg_conn):
|
||||||
|
"""All required indexes must be present in pg_indexes after migration."""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE tablename IN ('player_season_stats', 'evolution_card_state')
|
||||||
|
""")
|
||||||
|
found = {row[0] for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
expected = {
|
||||||
|
"player_season_stats_player_team_season_uniq",
|
||||||
|
"player_season_stats_team_season_idx",
|
||||||
|
"player_season_stats_player_season_idx",
|
||||||
|
"evolution_card_state_player_team_uniq",
|
||||||
|
}
|
||||||
|
assert expected.issubset(found), f"Missing indexes: {expected - found}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_fk_constraints_exist(pg_conn):
|
||||||
|
"""Foreign key constraints from evolution_card_state to player and team must exist."""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT tc.constraint_name, tc.table_name, kcu.column_name,
|
||||||
|
ccu.table_name AS foreign_table
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
JOIN information_schema.key_column_usage AS kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
JOIN information_schema.constraint_column_usage AS ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_name = 'evolution_card_state'
|
||||||
|
""")
|
||||||
|
fks = {(row[2], row[3]) for row in cur.fetchall()}
|
||||||
|
cur.close()
|
||||||
|
assert (
|
||||||
|
"player_id",
|
||||||
|
"player",
|
||||||
|
) in fks, "FK from evolution_card_state.player_id to player missing"
|
||||||
|
assert (
|
||||||
|
"team_id",
|
||||||
|
"team",
|
||||||
|
) in fks, "FK from evolution_card_state.team_id to team missing"
|
||||||
|
assert (
|
||||||
|
"track_id",
|
||||||
|
"evolution_track",
|
||||||
|
) in fks, "FK from evolution_card_state.track_id to evolution_track missing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_existing_data_preserved(pg_conn):
|
||||||
|
"""Adding columns to card/battingcard/pitchingcard must not destroy existing rows."""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
# Row counts should be non-negative and unchanged after migration
|
||||||
|
for table in ("card", "battingcard", "pitchingcard"):
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
count = cur.fetchone()[0]
|
||||||
|
assert count >= 0, f"Unexpected row count for {table}: {count}"
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytestmark_integration
|
||||||
|
def test_integration_new_columns_nullable(pg_conn):
|
||||||
|
"""card.variant and battingcard/pitchingcard.image_url must be nullable."""
|
||||||
|
cur = pg_conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT table_name, column_name, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE (table_name = 'card' AND column_name = 'variant')
|
||||||
|
OR (table_name IN ('battingcard', 'pitchingcard') AND column_name = 'image_url')
|
||||||
|
ORDER BY table_name, column_name
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
assert len(rows) == 3, f"Expected 3 new columns, found {len(rows)}"
|
||||||
|
for table_name, col_name, is_nullable, col_default in rows:
|
||||||
|
assert is_nullable == "YES", f"{table_name}.{col_name} must be nullable"
|
||||||
This table does not match the ORM. The base branch (
card-evolution) defines two separate models:BattingSeasonStats(tablebatting_season_stats) andPitchingSeasonStats(tablepitching_season_stats). This single combined table will leave both ORM models pointing at nonexistent tables.