From c6a87e35d4d427f965080d33b2eb5fe36ca92c52 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Wed, 31 Dec 2025 18:21:14 +0000 Subject: [PATCH] feat: complete CLI, Docker & deployment docs (Step 10.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PLAN.md | 12 +- README.md | 143 ++++++++++++++++++++++++ tests/test_cli_seed.py | 241 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli_serve.py | 188 +++++++++++++++++++++++++++++++ 4 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 README.md create mode 100644 tests/test_cli_seed.py create mode 100644 tests/test_cli_serve.py diff --git a/PLAN.md b/PLAN.md index 2346916..540cc7f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -395,12 +395,12 @@ tests verify implementation consistency, not exact spec values. - [x] **Commit checkpoint** (229842f) ### Step 10.3: CLI, Docker & Deployment -- [ ] Complete CLI: serve, seed, migrate commands -- [ ] Update flake.nix for Docker image build -- [ ] Create docker.nix -- [ ] Document deployment configuration -- [ ] Write tests: CLI commands work -- [ ] **Final commit** +- [x] Complete CLI: serve, seed, migrate commands +- [x] Update flake.nix for Docker image build +- [x] Create docker.nix +- [x] Document deployment configuration (README.md) +- [x] Write tests: CLI commands work (test_cli_seed.py, test_cli_serve.py) +- [x] **Final commit** --- diff --git a/README.md b/README.md new file mode 100644 index 0000000..62a5361 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# AnimalTrack + +Event-sourced animal tracking for poultry farm management. Built with FastHTML, MonsterUI, and SQLite. + +## Features + +- **Egg Collection** - Quick capture of daily egg counts by location +- **Feed Tracking** - Record purchases and distribution with cost tracking +- **Animal Registry** - Track cohorts, movements, and lifecycle events +- **Historical Queries** - Point-in-time resolution using interval projections +- **Event Sourcing** - Full audit trail with edit/delete support + +## Development Setup + +### Prerequisites + +- [Nix](https://nixos.org/download.html) with flakes enabled +- [direnv](https://direnv.net/) (optional but recommended) + +### Quick Start + +```bash +# Clone the repo +git clone +cd animaltrack + +# Enter dev environment +direnv allow # or: nix develop + +# Run the server +animaltrack serve +``` + +The server starts at http://localhost:3366 (3366 = EGG in leetspeak). + +### CLI Commands + +```bash +# Run database migrations +animaltrack migrate + +# Load seed data (users, locations, species, products, feed types) +animaltrack seed + +# Start the web server +animaltrack serve [--port 3366] [--host 0.0.0.0] + +# Create a new migration +animaltrack create-migration "add users table" +``` + +### Running Tests + +```bash +pytest tests/ -v +``` + +## Deployment + +### Docker + +Build the Docker image using Nix: + +```bash +nix build .#dockerImage +docker load < result +``` + +Run the container: + +```bash +docker run -d \ + -p 8080:3366 \ + -v /data/animaltrack:/var/lib/animaltrack \ + -e CSRF_SECRET="your-secret-here" \ + -e TRUSTED_PROXY_IPS="10.0.0.1,10.0.0.2" \ + gitea.v.paler.net/ppetru/animaltrack:latest +``` + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CSRF_SECRET` | **Yes** | - | Secret for CSRF token generation | +| `DB_PATH` | No | `animaltrack.db` | SQLite database path | +| `PORT` | No | `3366` | Server port | +| `AUTH_HEADER_NAME` | No | `X-Oidc-Username` | Header containing username from reverse proxy | +| `TRUSTED_PROXY_IPS` | No | (empty) | Comma-separated IPs that can set auth headers | +| `CSRF_COOKIE_NAME` | No | `csrf_token` | CSRF cookie name | +| `SEED_ON_START` | No | `false` | Run seeds on startup | +| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | +| `METRICS_ENABLED` | No | `true` | Enable Prometheus metrics at /metrics | +| `DEV_MODE` | No | `false` | Bypasses auth, sets default user for development | +| `BASE_PATH` | No | `/` | URL base path for reverse proxy setups | + +### Reverse Proxy Configuration + +AnimalTrack expects authentication to be handled by a reverse proxy (e.g., Authelia, Authentik, oauth2-proxy). The proxy should: + +1. Authenticate users via OIDC/OAuth2 +2. Forward the username in `X-Oidc-Username` header (configurable via `AUTH_HEADER_NAME`) +3. Be listed in `TRUSTED_PROXY_IPS` to be trusted + +Example Caddy configuration: + +``` +animaltrack.example.com { + forward_auth authelia:9091 { + uri /api/verify?rd=https://auth.example.com + copy_headers Remote-User Remote-Groups Remote-Email + header_up X-Oidc-Username {http.auth.resp.Remote-User} + } + reverse_proxy animaltrack:3366 +} +``` + +### Data Persistence + +Mount a volume to `/var/lib/animaltrack` for the SQLite database: + +```bash +-v /path/on/host:/var/lib/animaltrack +``` + +The database file will be created at `$DB_PATH` (default: `/var/lib/animaltrack/animaltrack.db` in Docker). + +### Health Check + +- `GET /healthz` - Returns 200 if database is writable, 503 otherwise +- `GET /metrics` - Prometheus metrics (when `METRICS_ENABLED=true`) + +## Architecture + +- **Event Sourcing** - All state changes are events. Events are immutable. +- **Projections** - Materialized views updated synchronously in transactions. +- **Interval Tables** - Track animal locations, tags, and attributes over time. +- **ULID IDs** - Time-ordered, sortable, unique identifiers. +- **Integer Timestamps** - Milliseconds since Unix epoch for all timestamps. +- **Integer Money** - All prices stored as cents for precision. + +## License + +Proprietary - All rights reserved. diff --git a/tests/test_cli_seed.py b/tests/test_cli_seed.py new file mode 100644 index 0000000..1e2fab4 --- /dev/null +++ b/tests/test_cli_seed.py @@ -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 diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py new file mode 100644 index 0000000..e75ea35 --- /dev/null +++ b/tests/test_cli_serve.py @@ -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