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:
2025-12-31 18:21:14 +00:00
parent 49871f60c5
commit c6a87e35d4
4 changed files with 578 additions and 6 deletions

241
tests/test_cli_seed.py Normal file
View 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
View 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