Implements Step 1.4: Creates migration for species, locations, products, feed_types, and users tables with full CHECK constraints. Adds Pydantic models with validation for all reference types including enums for ProductUnit and UserRole. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
406 lines
13 KiB
Python
406 lines
13 KiB
Python
# ABOUTME: Tests for reference table Pydantic models.
|
|
# ABOUTME: Validates Species, Location, Product, FeedType, and User models.
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from animaltrack.models.reference import (
|
|
FeedType,
|
|
Location,
|
|
Product,
|
|
ProductUnit,
|
|
Species,
|
|
User,
|
|
UserRole,
|
|
)
|
|
|
|
|
|
class TestSpecies:
|
|
"""Tests for the Species model."""
|
|
|
|
def test_valid_species(self):
|
|
"""Species with all required fields validates successfully."""
|
|
species = Species(
|
|
code="duck",
|
|
name="Duck",
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert species.code == "duck"
|
|
assert species.name == "Duck"
|
|
assert species.active is True
|
|
assert species.created_at_utc == 1704067200000
|
|
assert species.updated_at_utc == 1704067200000
|
|
|
|
def test_species_active_defaults_to_true(self):
|
|
"""Species active field defaults to True."""
|
|
species = Species(
|
|
code="goose",
|
|
name="Goose",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert species.active is True
|
|
|
|
def test_species_requires_code(self):
|
|
"""Species without code raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Species(
|
|
name="Duck",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_species_requires_name(self):
|
|
"""Species without name raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Species(
|
|
code="duck",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_species_requires_timestamps(self):
|
|
"""Species without timestamps raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Species(code="duck", name="Duck")
|
|
|
|
|
|
class TestLocation:
|
|
"""Tests for the Location model."""
|
|
|
|
def test_valid_location(self):
|
|
"""Location with valid 26-char ULID validates successfully."""
|
|
location = Location(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
name="Strip 1",
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert location.id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert location.name == "Strip 1"
|
|
|
|
def test_location_id_must_be_26_chars(self):
|
|
"""Location with ID not exactly 26 chars raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
Location(
|
|
id="short",
|
|
name="Strip 1",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_location_id_too_long(self):
|
|
"""Location with ID longer than 26 chars raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
Location(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAVX", # 27 chars
|
|
name="Strip 1",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_location_active_defaults_to_true(self):
|
|
"""Location active field defaults to True."""
|
|
location = Location(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
name="Strip 1",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert location.active is True
|
|
|
|
def test_location_requires_name(self):
|
|
"""Location without name raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Location(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
|
|
class TestProductUnit:
|
|
"""Tests for the ProductUnit enum."""
|
|
|
|
def test_piece_value(self):
|
|
"""ProductUnit.PIECE has value 'piece'."""
|
|
assert ProductUnit.PIECE.value == "piece"
|
|
|
|
def test_kg_value(self):
|
|
"""ProductUnit.KG has value 'kg'."""
|
|
assert ProductUnit.KG.value == "kg"
|
|
|
|
|
|
class TestProduct:
|
|
"""Tests for the Product model."""
|
|
|
|
def test_valid_product(self):
|
|
"""Product with all required fields validates successfully."""
|
|
product = Product(
|
|
code="egg.duck",
|
|
name="Duck Egg",
|
|
unit=ProductUnit.PIECE,
|
|
collectable=True,
|
|
sellable=True,
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert product.code == "egg.duck"
|
|
assert product.name == "Duck Egg"
|
|
assert product.unit == ProductUnit.PIECE
|
|
assert product.collectable is True
|
|
assert product.sellable is True
|
|
|
|
def test_product_unit_kg(self):
|
|
"""Product with kg unit validates successfully."""
|
|
product = Product(
|
|
code="meat",
|
|
name="Meat",
|
|
unit=ProductUnit.KG,
|
|
collectable=True,
|
|
sellable=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert product.unit == ProductUnit.KG
|
|
|
|
def test_product_unit_from_string(self):
|
|
"""Product accepts unit as string value."""
|
|
product = Product(
|
|
code="meat",
|
|
name="Meat",
|
|
unit="kg",
|
|
collectable=True,
|
|
sellable=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert product.unit == ProductUnit.KG
|
|
|
|
def test_product_invalid_unit(self):
|
|
"""Product with invalid unit raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Product(
|
|
code="meat",
|
|
name="Meat",
|
|
unit="invalid",
|
|
collectable=True,
|
|
sellable=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_product_active_defaults_to_true(self):
|
|
"""Product active field defaults to True."""
|
|
product = Product(
|
|
code="meat",
|
|
name="Meat",
|
|
unit=ProductUnit.KG,
|
|
collectable=True,
|
|
sellable=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert product.active is True
|
|
|
|
def test_product_requires_collectable(self):
|
|
"""Product without collectable raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Product(
|
|
code="meat",
|
|
name="Meat",
|
|
unit=ProductUnit.KG,
|
|
sellable=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
|
|
class TestFeedType:
|
|
"""Tests for the FeedType model."""
|
|
|
|
def test_valid_feed_type(self):
|
|
"""FeedType with all required fields validates successfully."""
|
|
feed_type = FeedType(
|
|
code="layer",
|
|
name="Layer Feed",
|
|
default_bag_size_kg=20,
|
|
protein_pct=16.5,
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert feed_type.code == "layer"
|
|
assert feed_type.name == "Layer Feed"
|
|
assert feed_type.default_bag_size_kg == 20
|
|
assert feed_type.protein_pct == 16.5
|
|
|
|
def test_feed_type_protein_pct_optional(self):
|
|
"""FeedType protein_pct is optional (can be None)."""
|
|
feed_type = FeedType(
|
|
code="starter",
|
|
name="Starter Feed",
|
|
default_bag_size_kg=20,
|
|
protein_pct=None,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert feed_type.protein_pct is None
|
|
|
|
def test_feed_type_default_bag_size_must_be_positive(self):
|
|
"""FeedType default_bag_size_kg must be >= 1."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
FeedType(
|
|
code="layer",
|
|
name="Layer Feed",
|
|
default_bag_size_kg=0,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert "greater than or equal to 1" in str(exc_info.value)
|
|
|
|
def test_feed_type_default_bag_size_negative(self):
|
|
"""FeedType negative default_bag_size_kg raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
FeedType(
|
|
code="layer",
|
|
name="Layer Feed",
|
|
default_bag_size_kg=-5,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_feed_type_active_defaults_to_true(self):
|
|
"""FeedType active field defaults to True."""
|
|
feed_type = FeedType(
|
|
code="grower",
|
|
name="Grower Feed",
|
|
default_bag_size_kg=25,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert feed_type.active is True
|
|
|
|
|
|
class TestUserRole:
|
|
"""Tests for the UserRole enum."""
|
|
|
|
def test_admin_value(self):
|
|
"""UserRole.ADMIN has value 'admin'."""
|
|
assert UserRole.ADMIN.value == "admin"
|
|
|
|
def test_recorder_value(self):
|
|
"""UserRole.RECORDER has value 'recorder'."""
|
|
assert UserRole.RECORDER.value == "recorder"
|
|
|
|
|
|
class TestUser:
|
|
"""Tests for the User model."""
|
|
|
|
def test_valid_user_admin(self):
|
|
"""User with admin role validates successfully."""
|
|
user = User(
|
|
username="ppetru",
|
|
role=UserRole.ADMIN,
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert user.username == "ppetru"
|
|
assert user.role == UserRole.ADMIN
|
|
|
|
def test_valid_user_recorder(self):
|
|
"""User with recorder role validates successfully."""
|
|
user = User(
|
|
username="ines",
|
|
role=UserRole.RECORDER,
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert user.role == UserRole.RECORDER
|
|
|
|
def test_user_role_from_string(self):
|
|
"""User accepts role as string value."""
|
|
user = User(
|
|
username="guest",
|
|
role="recorder",
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert user.role == UserRole.RECORDER
|
|
|
|
def test_user_invalid_role(self):
|
|
"""User with invalid role raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
User(
|
|
username="bad",
|
|
role="superuser",
|
|
active=True,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_user_active_defaults_to_true(self):
|
|
"""User active field defaults to True."""
|
|
user = User(
|
|
username="test",
|
|
role=UserRole.RECORDER,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
assert user.active is True
|
|
|
|
def test_user_requires_username(self):
|
|
"""User without username raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
User(
|
|
role=UserRole.ADMIN,
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
|
|
class TestTimestampValidation:
|
|
"""Tests for timestamp validation across all models."""
|
|
|
|
def test_species_negative_timestamp_rejected(self):
|
|
"""Species with negative timestamp raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Species(
|
|
code="duck",
|
|
name="Duck",
|
|
created_at_utc=-1,
|
|
updated_at_utc=1704067200000,
|
|
)
|
|
|
|
def test_location_negative_timestamp_rejected(self):
|
|
"""Location with negative timestamp raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Location(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
name="Strip 1",
|
|
created_at_utc=1704067200000,
|
|
updated_at_utc=-1,
|
|
)
|
|
|
|
def test_product_zero_timestamp_allowed(self):
|
|
"""Product with zero timestamp (Unix epoch) is allowed."""
|
|
product = Product(
|
|
code="egg",
|
|
name="Egg",
|
|
unit=ProductUnit.PIECE,
|
|
collectable=True,
|
|
sellable=True,
|
|
created_at_utc=0,
|
|
updated_at_utc=0,
|
|
)
|
|
assert product.created_at_utc == 0
|