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:
12
PLAN.md
12
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**
|
||||
|
||||
---
|
||||
|
||||
|
||||
143
README.md
Normal file
143
README.md
Normal file
@@ -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 <repo-url>
|
||||
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.
|
||||
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