Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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."""
Expand Down
15 changes: 15 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -19,22 +22,34 @@ 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)

assert reloaded.IS_PRODUCTION is True
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)
100 changes: 99 additions & 1 deletion tests/test_seed_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
2 changes: 2 additions & 0 deletions typer_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
121 changes: 89 additions & 32 deletions typer_bot/dev/seed_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions typer_bot/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading