diff --git a/README.md b/README.md index cb0c0da..3fa38a5 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ uv run python -m typer_bot.dev.seed_test_data --tester-user-id "your_discord_use uv run python -m typer_bot ``` +Disposable non-production deployments can auto-seed an empty database by setting `SEED_TEST_DATA=true` and `TEST_GUILD_ID`. + Run checks: ```bash diff --git a/tests/test_bot.py b/tests/test_bot.py index 4f14960..e0cbe3a 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -50,6 +50,7 @@ async def bot_instance(self): ): bot = TyperBot.__new__(TyperBot) bot.db = MagicMock() + bot.db.db_path = "test.db" bot.db.initialize = AsyncMock() bot.thread_handler = MagicMock() bot.load_extension = AsyncMock() @@ -63,6 +64,13 @@ async def test_setup_hook_initializes_database(self, bot_instance): await bot_instance.setup_hook() bot_instance.db.initialize.assert_called_once() + @pytest.mark.asyncio + async def test_setup_hook_runs_optional_auto_seed_after_database_init(self, bot_instance): + with patch("typer_bot.bot.maybe_auto_seed_test_data", AsyncMock()) as auto_seed: + await bot_instance.setup_hook() + + auto_seed.assert_awaited_once_with("test.db") + @pytest.mark.asyncio async def test_setup_hook_loads_user_commands(self, bot_instance): """User commands cog provides /predict and /standings.""" diff --git a/tests/test_config.py b/tests/test_config.py index f76c1ea..8912ad8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,6 +11,9 @@ def test_local_data_defaults_when_env_missing(self, monkeypatch): monkeypatch.delenv("DATA_DIR", raising=False) monkeypatch.delenv("DB_PATH", raising=False) monkeypatch.delenv("BACKUP_DIR", raising=False) + monkeypatch.delenv("SEED_TEST_DATA", raising=False) + monkeypatch.delenv("TEST_GUILD_ID", raising=False) + monkeypatch.delenv("TEST_USER_ID", raising=False) reloaded = importlib.reload(config_module) @@ -19,12 +22,18 @@ def test_local_data_defaults_when_env_missing(self, monkeypatch): assert reloaded.DATA_DIR == "./data" assert reloaded.DB_PATH == "./data/typer.db" assert reloaded.BACKUP_DIR == "./data/backups" + assert reloaded.SEED_TEST_DATA is False + assert reloaded.TEST_GUILD_ID is None + assert reloaded.TEST_USER_ID is None def test_explicit_env_overrides_defaults(self, monkeypatch): monkeypatch.setenv("ENVIRONMENT", "production") monkeypatch.setenv("DATA_DIR", "/app/data") monkeypatch.setenv("DB_PATH", "/app/data/custom.db") monkeypatch.setenv("BACKUP_DIR", "/app/data/custom-backups") + monkeypatch.setenv("SEED_TEST_DATA", "true") + monkeypatch.setenv("TEST_GUILD_ID", "guild-1") + monkeypatch.setenv("TEST_USER_ID", "user-1") reloaded = importlib.reload(config_module) @@ -32,9 +41,15 @@ def test_explicit_env_overrides_defaults(self, monkeypatch): assert reloaded.DATA_DIR == "/app/data" assert reloaded.DB_PATH == "/app/data/custom.db" assert reloaded.BACKUP_DIR == "/app/data/custom-backups" + assert reloaded.SEED_TEST_DATA is True + assert reloaded.TEST_GUILD_ID == "guild-1" + assert reloaded.TEST_USER_ID == "user-1" monkeypatch.delenv("ENVIRONMENT", raising=False) monkeypatch.delenv("DATA_DIR", raising=False) monkeypatch.delenv("DB_PATH", raising=False) monkeypatch.delenv("BACKUP_DIR", raising=False) + monkeypatch.delenv("SEED_TEST_DATA", raising=False) + monkeypatch.delenv("TEST_GUILD_ID", raising=False) + monkeypatch.delenv("TEST_USER_ID", raising=False) importlib.reload(reloaded) diff --git a/tests/test_seed_test_data.py b/tests/test_seed_test_data.py index ee45307..f985ce5 100644 --- a/tests/test_seed_test_data.py +++ b/tests/test_seed_test_data.py @@ -4,7 +4,12 @@ import pytest -from typer_bot.dev.seed_test_data import DEFAULT_MANUAL_GUILD_ID, seed_mixed_test_data +from typer_bot.dev.seed_test_data import ( + DEFAULT_MANUAL_GUILD_ID, + maybe_auto_seed_test_data, + seed_mixed_test_data, +) +from typer_bot.utils import now def _write_cleanup_artifacts(temp_db_path: str, backup_dir: Path) -> None: @@ -134,3 +139,96 @@ async def test_seed_mixed_data_refuses_non_default_reset_without_force(tmp_path) with pytest.raises(ValueError, match="--force-reset"): await seed_mixed_test_data(str(db_path), str(backup_dir), None) + + +@pytest.mark.asyncio +async def test_auto_seed_skips_when_disabled(tmp_path): + db_path = tmp_path / "preview.db" + + seeded = await maybe_auto_seed_test_data(str(db_path), enabled=False) + + assert seeded is False + assert not db_path.exists() + + +@pytest.mark.asyncio +async def test_auto_seed_skips_in_production(tmp_path): + db_path = tmp_path / "preview.db" + + seeded = await maybe_auto_seed_test_data( + str(db_path), + enabled=True, + is_production=True, + environment="production", + guild_id="111111", + ) + + assert seeded is False + assert not db_path.exists() + + +@pytest.mark.asyncio +async def test_auto_seed_requires_guild_id(temp_db_path): + from typer_bot.database import Database + + db = Database(temp_db_path) + await db.initialize() + + with pytest.raises(RuntimeError, match="TEST_GUILD_ID"): + await maybe_auto_seed_test_data( + temp_db_path, + enabled=True, + is_production=False, + environment="preview", + guild_id=None, + ) + + +@pytest.mark.asyncio +async def test_auto_seed_populates_empty_non_production_database(temp_db_path): + from typer_bot.database import Database + + db = Database(temp_db_path) + await db.initialize() + + seeded = await maybe_auto_seed_test_data( + temp_db_path, + enabled=True, + is_production=False, + environment="preview", + guild_id="111111", + tester_user_id="tester-1", + ) + + seeded_db = Database(temp_db_path) + open_fixtures = await seeded_db.get_open_fixtures("111111") + week_two = await seeded_db.get_fixture_by_week("111111", 2) + + assert seeded is True + assert [fixture["week_number"] for fixture in open_fixtures] == [2, 3] + assert week_two is not None + assert await seeded_db.get_prediction(week_two["id"], "tester-1", "111111") is not None + + +@pytest.mark.asyncio +async def test_auto_seed_does_not_reset_non_empty_database(temp_db_path): + from typer_bot.database import Database + + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 99, ["Existing A - Existing B"], now()) + + seeded = await maybe_auto_seed_test_data( + temp_db_path, + enabled=True, + is_production=False, + environment="preview", + guild_id="111111", + ) + + existing_fixture = await db.get_fixture_by_id(fixture_id, "111111") + open_fixtures = await db.get_open_fixtures("111111") + + assert seeded is False + assert existing_fixture is not None + assert [fixture["week_number"] for fixture in open_fixtures] == [99] diff --git a/typer_bot/bot.py b/typer_bot/bot.py index 89fb139..63ebeed 100644 --- a/typer_bot/bot.py +++ b/typer_bot/bot.py @@ -11,6 +11,7 @@ from dotenv import load_dotenv from typer_bot.database import Database +from typer_bot.dev.seed_test_data import maybe_auto_seed_test_data from typer_bot.handlers.thread_prediction_handler import ThreadPredictionHandler from typer_bot.utils import format_for_discord, now from typer_bot.utils.logger import set_log_context, set_trace_id @@ -91,6 +92,7 @@ async def setup_hook(self): try: await self.db.initialize() logger.info("Database initialized successfully") + await maybe_auto_seed_test_data(self.db.db_path) except Exception: logger.exception("Database initialization failed") raise diff --git a/typer_bot/dev/seed_test_data.py b/typer_bot/dev/seed_test_data.py index 54cb17d..4e47959 100644 --- a/typer_bot/dev/seed_test_data.py +++ b/typer_bot/dev/seed_test_data.py @@ -9,9 +9,19 @@ from datetime import timedelta from pathlib import Path +import aiosqlite + from typer_bot.database import Database from typer_bot.utils import calculate_points, now -from typer_bot.utils.config import BACKUP_DIR, DB_PATH +from typer_bot.utils.config import ( + BACKUP_DIR, + DB_PATH, + ENVIRONMENT, + IS_PRODUCTION, + SEED_TEST_DATA, + TEST_GUILD_ID, + TEST_USER_ID, +) from typer_bot.utils.logger import setup_logging SAMPLE_GAMES = [ @@ -83,39 +93,23 @@ def _build_seed_users() -> list[dict[str, str]]: return [user.copy() for user in SYNTHETIC_USERS] -async def seed_mixed_test_data( - db_path: str, - backup_dir: str, - tester_user_id: str | None, - guild_id: str = DEFAULT_MANUAL_GUILD_ID, - *, - force_reset: bool = False, -) -> None: - """Reset the target SQLite files and seed one mixed manual-testing scenario. - - Args: - db_path: SQLite database file to recreate and seed. - backup_dir: Backup directory removed before reseeding. - tester_user_id: Real Discord user ID added only to the open-fixture seed data. - guild_id: Discord guild ID assigned to seeded fixtures. - force_reset: Allows resetting paths outside ``./.local/manual-discord-test``. - - Raises: - ValueError: Target paths are outside the default manual-test directory and - ``force_reset`` is not enabled. - - Notes: - The reset removes the database file, its WAL/SHM sidecars, and the backup - directory. The seed creates three fixtures: one scored past fixture, one - open fixture with saved predictions, and one overdue fixture with a late - prediction. This touches SQLite only. It does not post announcements, - create threads, or run any Discord workflows. - """ - _reset_database_files(db_path, backup_dir, force_reset) +async def _database_has_seedable_data(db_path: str) -> bool: + async with aiosqlite.connect(db_path) as db: + for table_name in ( + "guild_config", + "seasons", + "fixtures", + "predictions", + "results", + "scores", + ): + async with db.execute(f"SELECT 1 FROM {table_name} LIMIT 1") as cursor: + if await cursor.fetchone() is not None: + return True + return False - db = Database(db_path) - await db.initialize() +async def _seed_mixed_rows(db: Database, tester_user_id: str | None, guild_id: str) -> None: current_time = now() users = _build_seed_users() @@ -203,6 +197,69 @@ async def seed_mixed_test_data( ) +async def maybe_auto_seed_test_data( + db_path: str, + *, + enabled: bool = SEED_TEST_DATA, + environment: str = ENVIRONMENT, + is_production: bool = IS_PRODUCTION, + guild_id: str | None = TEST_GUILD_ID, + tester_user_id: str | None = TEST_USER_ID, +) -> bool: + """Seed disposable non-production data when explicitly enabled and empty.""" + if not enabled: + return False + if is_production: + logger.warning("Skipping test data auto-seed in production environment") + return False + if not guild_id: + raise RuntimeError("TEST_GUILD_ID is required when SEED_TEST_DATA is enabled.") + if await _database_has_seedable_data(db_path): + logger.info("Skipping test data auto-seed because database is not empty") + return False + + await _seed_mixed_rows(Database(db_path), tester_user_id, guild_id) + logger.info( + "Seeded non-production test data", extra={"environment": environment, "guild_id": guild_id} + ) + return True + + +async def seed_mixed_test_data( + db_path: str, + backup_dir: str, + tester_user_id: str | None, + guild_id: str = DEFAULT_MANUAL_GUILD_ID, + *, + force_reset: bool = False, +) -> None: + """Reset the target SQLite files and seed one mixed manual-testing scenario. + + Args: + db_path: SQLite database file to recreate and seed. + backup_dir: Backup directory removed before reseeding. + tester_user_id: Real Discord user ID added only to the open-fixture seed data. + guild_id: Discord guild ID assigned to seeded fixtures. + force_reset: Allows resetting paths outside ``./.local/manual-discord-test``. + + Raises: + ValueError: Target paths are outside the default manual-test directory and + ``force_reset`` is not enabled. + + Notes: + The reset removes the database file, its WAL/SHM sidecars, and the backup + directory. The seed creates three fixtures: one scored past fixture, one + open fixture with saved predictions, and one overdue fixture with a late + prediction. This touches SQLite only. It does not post announcements, + create threads, or run any Discord workflows. + """ + _reset_database_files(db_path, backup_dir, force_reset) + + db = Database(db_path) + await db.initialize() + await _seed_mixed_rows(db, tester_user_id, guild_id) + + async def _async_main(tester_user_id: str | None, guild_id: str, force_reset: bool) -> None: await seed_mixed_test_data( DB_PATH, diff --git a/typer_bot/utils/config.py b/typer_bot/utils/config.py index de1249c..9ec89ed 100644 --- a/typer_bot/utils/config.py +++ b/typer_bot/utils/config.py @@ -16,3 +16,6 @@ DATA_DIR = os.getenv("DATA_DIR", "./data") DB_PATH = os.getenv("DB_PATH", f"{DATA_DIR}/typer.db") BACKUP_DIR = os.getenv("BACKUP_DIR", f"{DATA_DIR}/backups") +SEED_TEST_DATA = os.getenv("SEED_TEST_DATA", "").lower() in ("1", "true", "yes", "on") +TEST_GUILD_ID = os.getenv("TEST_GUILD_ID") +TEST_USER_ID = os.getenv("TEST_USER_ID")