feat: SQL migration for evolution tables and variant/image_url columns (#69)
Closes #69 - Create migrations/2026-03-12_add_evolution_tables.sql: idempotent PostgreSQL migration (BEGIN/COMMIT, all IF NOT EXISTS) that creates player_season_stats, evolution_track, evolution_card_state, evolution_tier_boost, and evolution_cosmetic tables; adds card.variant (INTEGER NULL DEFAULT NULL), battingcard.image_url (VARCHAR(500) NULL), and pitchingcard.image_url (VARCHAR(500) NULL). - Add tests/test_evolution_migration.py: 16 unit tests validate SQL file structure (tables, columns, indexes, FK references, idempotency); 6 integration tests annotated for PostgreSQL execution when POSTGRES_HOST is set. - Add tests/__init__.py and tests/conftest.py (shared test infrastructure required for import isolation). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a66ef9bd7c
commit
11a499c0ec
179
migrations/2026-03-12_add_evolution_tables.sql
Normal file
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,
|
||||
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,
|
||||
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,
|
||||
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
|
||||
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
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- 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
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- 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
|
||||
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
0
tests/__init__.py
Normal file
14
tests/conftest.py
Normal file
14
tests/conftest.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""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
|
||||
|
||||
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")
|
||||
423
tests/test_evolution_migration.py
Normal file
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"
|
||||
Loading…
Reference in New Issue
Block a user