feat: complete CLI, Docker & deployment docs (Step 10.3)
- Add comprehensive CLI tests for seed and serve commands - Create README.md with development setup, deployment guide, and environment variable reference - Mark Step 10.3 as complete in PLAN.md This completes the final implementation step. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
241
tests/test_cli_seed.py
Normal file
241
tests/test_cli_seed.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# ABOUTME: End-to-end tests for seed CLI command.
|
||||
# ABOUTME: Tests seeding reference data via subprocess.
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from animaltrack.db import get_db
|
||||
from animaltrack.repositories import (
|
||||
FeedTypeRepository,
|
||||
LocationRepository,
|
||||
ProductRepository,
|
||||
SpeciesRepository,
|
||||
UserRepository,
|
||||
)
|
||||
|
||||
# Get the project root directory (parent of tests/)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestSeedCLI:
|
||||
"""End-to-end tests for seed command."""
|
||||
|
||||
def test_seed_command_success(self, tmp_path):
|
||||
"""Should seed data via CLI and exit 0."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
assert "seed data loaded" in result.stdout.lower()
|
||||
|
||||
def test_seed_creates_users(self, tmp_path):
|
||||
"""Should create expected users."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
|
||||
# Verify users were created
|
||||
db = get_db(str(db_path))
|
||||
repo = UserRepository(db)
|
||||
users = repo.list_all()
|
||||
|
||||
usernames = {u.username for u in users}
|
||||
assert "ppetru" in usernames
|
||||
assert "ines" in usernames
|
||||
assert "guest" in usernames
|
||||
|
||||
def test_seed_creates_locations(self, tmp_path):
|
||||
"""Should create expected locations."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
|
||||
# Verify locations were created
|
||||
db = get_db(str(db_path))
|
||||
repo = LocationRepository(db)
|
||||
locations = repo.list_all()
|
||||
|
||||
names = {loc.name for loc in locations}
|
||||
assert "Strip 1" in names
|
||||
assert "Strip 4" in names
|
||||
assert "Nursery 1" in names
|
||||
assert "Nursery 4" in names
|
||||
assert len(locations) == 8
|
||||
|
||||
def test_seed_creates_species(self, tmp_path):
|
||||
"""Should create expected species."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
|
||||
# Verify species were created
|
||||
db = get_db(str(db_path))
|
||||
repo = SpeciesRepository(db)
|
||||
species_list = repo.list_all()
|
||||
|
||||
codes = {s.code for s in species_list}
|
||||
assert "duck" in codes
|
||||
assert "goose" in codes
|
||||
assert "sheep" in codes
|
||||
|
||||
def test_seed_creates_products(self, tmp_path):
|
||||
"""Should create expected products."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
|
||||
# Verify products were created
|
||||
db = get_db(str(db_path))
|
||||
repo = ProductRepository(db)
|
||||
products = repo.list_all()
|
||||
|
||||
codes = {p.code for p in products}
|
||||
assert "egg.duck" in codes
|
||||
assert "meat" in codes
|
||||
assert "offal" in codes
|
||||
assert "fat" in codes
|
||||
assert "bones" in codes
|
||||
assert "feathers" in codes
|
||||
assert "down" in codes
|
||||
|
||||
def test_seed_creates_feed_types(self, tmp_path):
|
||||
"""Should create expected feed types."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
|
||||
|
||||
# Verify feed types were created
|
||||
db = get_db(str(db_path))
|
||||
repo = FeedTypeRepository(db)
|
||||
feed_types = repo.list_all()
|
||||
|
||||
codes = {f.code for f in feed_types}
|
||||
assert "starter" in codes
|
||||
assert "grower" in codes
|
||||
assert "layer" in codes
|
||||
|
||||
def test_seed_is_idempotent(self, tmp_path):
|
||||
"""Running seed twice should produce same result."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["DB_PATH"] = str(db_path)
|
||||
env["CSRF_SECRET"] = "test-secret-for-csrf"
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
# Run seed first time
|
||||
result1 = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
assert result1.returncode == 0, f"stdout: {result1.stdout}, stderr: {result1.stderr}"
|
||||
|
||||
# Run seed second time
|
||||
result2 = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "seed"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
assert result2.returncode == 0, f"stdout: {result2.stdout}, stderr: {result2.stderr}"
|
||||
|
||||
# Verify counts haven't doubled
|
||||
db = get_db(str(db_path))
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
assert len(user_repo.list_all()) == 3
|
||||
|
||||
location_repo = LocationRepository(db)
|
||||
assert len(location_repo.list_all()) == 8
|
||||
|
||||
species_repo = SpeciesRepository(db)
|
||||
assert len(species_repo.list_all()) == 3
|
||||
|
||||
product_repo = ProductRepository(db)
|
||||
assert len(product_repo.list_all()) == 7
|
||||
|
||||
feed_repo = FeedTypeRepository(db)
|
||||
assert len(feed_repo.list_all()) == 3
|
||||
188
tests/test_cli_serve.py
Normal file
188
tests/test_cli_serve.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# ABOUTME: End-to-end tests for serve CLI command.
|
||||
# ABOUTME: Tests server setup and argument handling via mocked uvicorn.
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from animaltrack.cli import main
|
||||
|
||||
# Get the project root directory (parent of tests/)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestServeCLI:
|
||||
"""Tests for serve command."""
|
||||
|
||||
def test_serve_runs_migrations_first(self, tmp_path, monkeypatch):
|
||||
"""Serve should run migrations before starting server."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
# Mock uvicorn.run to prevent server from starting
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
|
||||
|
||||
main()
|
||||
|
||||
# Verify migrations were called
|
||||
mock_migrations.assert_called_once()
|
||||
# Verify uvicorn was called after migrations
|
||||
mock_uvicorn.assert_called_once()
|
||||
|
||||
def test_serve_creates_app(self, tmp_path, monkeypatch):
|
||||
"""Serve should create the app before starting."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
|
||||
with patch("animaltrack.web.app.create_app") as mock_create_app:
|
||||
mock_app = MagicMock()
|
||||
mock_create_app.return_value = (mock_app, None)
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
|
||||
|
||||
main()
|
||||
|
||||
# Verify create_app was called
|
||||
mock_create_app.assert_called_once()
|
||||
# Verify uvicorn got the app
|
||||
mock_uvicorn.assert_called_once()
|
||||
call_kwargs = mock_uvicorn.call_args
|
||||
assert call_kwargs[0][0] == mock_app
|
||||
|
||||
def test_serve_uses_default_port(self, tmp_path, monkeypatch):
|
||||
"""Serve should use port 3366 by default."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
|
||||
with patch("animaltrack.web.app.create_app") as mock_create_app:
|
||||
mock_app = MagicMock()
|
||||
mock_create_app.return_value = (mock_app, None)
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
|
||||
|
||||
main()
|
||||
|
||||
call_kwargs = mock_uvicorn.call_args
|
||||
assert call_kwargs[1]["port"] == 3366
|
||||
|
||||
def test_serve_custom_port(self, tmp_path, monkeypatch):
|
||||
"""Serve should accept custom port."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
|
||||
with patch("animaltrack.web.app.create_app") as mock_create_app:
|
||||
mock_app = MagicMock()
|
||||
mock_create_app.return_value = (mock_app, None)
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve", "--port", "8080"])
|
||||
|
||||
main()
|
||||
|
||||
call_kwargs = mock_uvicorn.call_args
|
||||
assert call_kwargs[1]["port"] == 8080
|
||||
|
||||
def test_serve_custom_host(self, tmp_path, monkeypatch):
|
||||
"""Serve should accept custom host."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
|
||||
with patch("animaltrack.web.app.create_app") as mock_create_app:
|
||||
mock_app = MagicMock()
|
||||
mock_create_app.return_value = (mock_app, None)
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve", "--host", "127.0.0.1"])
|
||||
|
||||
main()
|
||||
|
||||
call_kwargs = mock_uvicorn.call_args
|
||||
assert call_kwargs[1]["host"] == "127.0.0.1"
|
||||
|
||||
def test_serve_uses_default_host(self, tmp_path, monkeypatch):
|
||||
"""Serve should use 0.0.0.0 as default host."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = True
|
||||
|
||||
with patch("animaltrack.web.app.create_app") as mock_create_app:
|
||||
mock_app = MagicMock()
|
||||
mock_create_app.return_value = (mock_app, None)
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
|
||||
|
||||
main()
|
||||
|
||||
call_kwargs = mock_uvicorn.call_args
|
||||
assert call_kwargs[1]["host"] == "0.0.0.0"
|
||||
|
||||
def test_serve_exits_on_migration_failure(self, tmp_path, monkeypatch):
|
||||
"""Serve should exit 1 if migrations fail."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("CSRF_SECRET", "test-secret-for-csrf")
|
||||
|
||||
with patch("uvicorn.run") as mock_uvicorn:
|
||||
with patch("animaltrack.migrations.run_migrations") as mock_migrations:
|
||||
mock_migrations.return_value = False
|
||||
monkeypatch.setattr("sys.argv", ["animaltrack", "serve"])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
# Uvicorn should not be called if migrations fail
|
||||
mock_uvicorn.assert_not_called()
|
||||
|
||||
|
||||
class TestServeHelpAndArgs:
|
||||
"""Tests for serve command help and argument parsing."""
|
||||
|
||||
def test_serve_help(self):
|
||||
"""Should display help for serve command."""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "animaltrack.cli", "serve", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "--port" in result.stdout
|
||||
assert "--host" in result.stdout
|
||||
Reference in New Issue
Block a user