feat: SQL migration for evolution tables and variant/image_url columns (#69) #84

Closed
Claude wants to merge 2 commits from ai/paper-dynasty-database#69 into card-evolution
4 changed files with 650 additions and 0 deletions

View 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
Review

This table does not match the ORM. The base branch (card-evolution) defines two separate models: BattingSeasonStats (table batting_season_stats) and PitchingSeasonStats (table pitching_season_stats). This single combined table will leave both ORM models pointing at nonexistent tables.

This table does not match the ORM. The base branch (`card-evolution`) defines two separate models: `BattingSeasonStats` (table `batting_season_stats`) and `PitchingSeasonStats` (table `pitching_season_stats`). This single combined table will leave both ORM models pointing at nonexistent tables.
-- --------------------------------------------
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,
Review

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).

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,
Review

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.

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,
Review

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?

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
Review

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.

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
);
Review

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.

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
);
Review

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.

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
Review

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.

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
View File

48
tests/conftest.py Normal file
View 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(
Review

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).
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()

View 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 t1t4 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"