From 2e28827683c5fb37419f25240d2c9ee531ac5a27 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 27 Dec 2025 19:18:08 +0000 Subject: [PATCH] feat: add reference tables schema and Pydantic models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- migrations/0001-reference-tables.sql | 52 +++ src/animaltrack/models/__init__.py | 22 ++ src/animaltrack/models/reference.py | 129 ++++++++ tests/test_migration_reference_tables.py | 360 ++++++++++++++++++++ tests/test_models_reference.py | 405 +++++++++++++++++++++++ 5 files changed, 968 insertions(+) create mode 100644 migrations/0001-reference-tables.sql create mode 100644 src/animaltrack/models/__init__.py create mode 100644 src/animaltrack/models/reference.py create mode 100644 tests/test_migration_reference_tables.py create mode 100644 tests/test_models_reference.py diff --git a/migrations/0001-reference-tables.sql b/migrations/0001-reference-tables.sql new file mode 100644 index 0000000..9cf49f5 --- /dev/null +++ b/migrations/0001-reference-tables.sql @@ -0,0 +1,52 @@ +-- ABOUTME: Migration 0001 - reference-tables +-- ABOUTME: Creates the five reference tables: species, locations, products, feed_types, users. + +-- Species: lookup table for animal species (duck, goose, sheep) +CREATE TABLE species ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at_utc INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); + +-- Locations: physical locations (Strip 1-4, Nursery 1-4) +CREATE TABLE locations ( + id TEXT PRIMARY KEY CHECK(length(id) = 26), + name TEXT NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at_utc INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); + +-- Products: things that can be collected/sold (eggs, meat, etc.) +CREATE TABLE products ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + unit TEXT NOT NULL CHECK(unit IN ('piece', 'kg')), + collectable INTEGER NOT NULL CHECK(collectable IN (0, 1)), + sellable INTEGER NOT NULL CHECK(sellable IN (0, 1)), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at_utc INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); + +-- Feed types: types of feed (starter, grower, layer) +CREATE TABLE feed_types ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + default_bag_size_kg INTEGER NOT NULL CHECK(default_bag_size_kg >= 1), + protein_pct REAL NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at_utc INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); + +-- Users: system users with roles +CREATE TABLE users ( + username TEXT PRIMARY KEY, + role TEXT NOT NULL CHECK(role IN ('admin', 'recorder')), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at_utc INTEGER NOT NULL, + updated_at_utc INTEGER NOT NULL +); diff --git a/src/animaltrack/models/__init__.py b/src/animaltrack/models/__init__.py new file mode 100644 index 0000000..04282b2 --- /dev/null +++ b/src/animaltrack/models/__init__.py @@ -0,0 +1,22 @@ +# ABOUTME: Models package for AnimalTrack. +# ABOUTME: Exports all Pydantic models and enums for reference data. + +from animaltrack.models.reference import ( + FeedType, + Location, + Product, + ProductUnit, + Species, + User, + UserRole, +) + +__all__ = [ + "FeedType", + "Location", + "Product", + "ProductUnit", + "Species", + "User", + "UserRole", +] diff --git a/src/animaltrack/models/reference.py b/src/animaltrack/models/reference.py new file mode 100644 index 0000000..b3421b1 --- /dev/null +++ b/src/animaltrack/models/reference.py @@ -0,0 +1,129 @@ +# ABOUTME: Pydantic models for reference tables (species, locations, products, feed_types, users). +# ABOUTME: These models validate data before database insertion and provide type safety. + +from enum import Enum + +from pydantic import BaseModel, Field, field_validator + + +class ProductUnit(str, Enum): + """Unit of measurement for products.""" + + PIECE = "piece" + KG = "kg" + + +class UserRole(str, Enum): + """Role for system users.""" + + ADMIN = "admin" + RECORDER = "recorder" + + +class Species(BaseModel): + """Animal species reference data.""" + + code: str + name: str + active: bool = True + created_at_utc: int + updated_at_utc: int + + @field_validator("created_at_utc", "updated_at_utc") + @classmethod + def timestamp_must_be_non_negative(cls, v: int) -> int: + """Timestamps must be >= 0 (milliseconds since Unix epoch).""" + if v < 0: + msg = "Timestamp must be non-negative" + raise ValueError(msg) + return v + + +class Location(BaseModel): + """Physical location reference data.""" + + id: str = Field(..., min_length=26, max_length=26) + name: str + active: bool = True + created_at_utc: int + updated_at_utc: int + + @field_validator("id") + @classmethod + def id_must_be_26_chars(cls, v: str) -> str: + """Location ID must be exactly 26 characters (ULID format).""" + if len(v) != 26: + msg = "Location ID must be exactly 26 characters" + raise ValueError(msg) + return v + + @field_validator("created_at_utc", "updated_at_utc") + @classmethod + def timestamp_must_be_non_negative(cls, v: int) -> int: + """Timestamps must be >= 0 (milliseconds since Unix epoch).""" + if v < 0: + msg = "Timestamp must be non-negative" + raise ValueError(msg) + return v + + +class Product(BaseModel): + """Product reference data (eggs, meat, etc.).""" + + code: str + name: str + unit: ProductUnit + collectable: bool + sellable: bool + active: bool = True + created_at_utc: int + updated_at_utc: int + + @field_validator("created_at_utc", "updated_at_utc") + @classmethod + def timestamp_must_be_non_negative(cls, v: int) -> int: + """Timestamps must be >= 0 (milliseconds since Unix epoch).""" + if v < 0: + msg = "Timestamp must be non-negative" + raise ValueError(msg) + return v + + +class FeedType(BaseModel): + """Feed type reference data.""" + + code: str + name: str + default_bag_size_kg: int = Field(..., ge=1) + protein_pct: float | None = None + active: bool = True + created_at_utc: int + updated_at_utc: int + + @field_validator("created_at_utc", "updated_at_utc") + @classmethod + def timestamp_must_be_non_negative(cls, v: int) -> int: + """Timestamps must be >= 0 (milliseconds since Unix epoch).""" + if v < 0: + msg = "Timestamp must be non-negative" + raise ValueError(msg) + return v + + +class User(BaseModel): + """System user reference data.""" + + username: str + role: UserRole + active: bool = True + created_at_utc: int + updated_at_utc: int + + @field_validator("created_at_utc", "updated_at_utc") + @classmethod + def timestamp_must_be_non_negative(cls, v: int) -> int: + """Timestamps must be >= 0 (milliseconds since Unix epoch).""" + if v < 0: + msg = "Timestamp must be non-negative" + raise ValueError(msg) + return v diff --git a/tests/test_migration_reference_tables.py b/tests/test_migration_reference_tables.py new file mode 100644 index 0000000..ae70c25 --- /dev/null +++ b/tests/test_migration_reference_tables.py @@ -0,0 +1,360 @@ +# ABOUTME: Tests for the reference tables migration (0001-reference-tables.sql). +# ABOUTME: Validates that tables are created with correct schema and constraints. + +import apsw +import pytest + +from animaltrack.db import get_db +from animaltrack.migrations import run_migrations + + +@pytest.fixture +def migrated_db(tmp_path): + """Create a database with migrations applied.""" + db_path = str(tmp_path / "test.db") + migrations_dir = "migrations" + run_migrations(db_path, migrations_dir, verbose=False) + return get_db(db_path) + + +class TestMigrationCreatesAllTables: + """Tests that migration creates all reference tables.""" + + def test_species_table_exists(self, migrated_db): + """Migration creates species table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='species'" + ).fetchone() + assert result is not None + assert result[0] == "species" + + def test_locations_table_exists(self, migrated_db): + """Migration creates locations table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='locations'" + ).fetchone() + assert result is not None + assert result[0] == "locations" + + def test_products_table_exists(self, migrated_db): + """Migration creates products table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='products'" + ).fetchone() + assert result is not None + assert result[0] == "products" + + def test_feed_types_table_exists(self, migrated_db): + """Migration creates feed_types table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='feed_types'" + ).fetchone() + assert result is not None + assert result[0] == "feed_types" + + def test_users_table_exists(self, migrated_db): + """Migration creates users table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" + ).fetchone() + assert result is not None + assert result[0] == "users" + + +class TestSpeciesTable: + """Tests for species table schema and constraints.""" + + def test_insert_valid_species(self, migrated_db): + """Can insert valid species data.""" + migrated_db.execute( + """ + INSERT INTO species (code, name, active, created_at_utc, updated_at_utc) + VALUES ('duck', 'Duck', 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT code, name, active FROM species WHERE code='duck'" + ).fetchone() + assert result == ("duck", "Duck", 1) + + def test_active_check_constraint(self, migrated_db): + """Species active must be 0 or 1.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO species (code, name, active, created_at_utc, updated_at_utc) + VALUES ('invalid', 'Invalid', 2, 1704067200000, 1704067200000) + """ + ) + + def test_code_is_primary_key(self, migrated_db): + """Species code is primary key (duplicate rejected).""" + migrated_db.execute( + """ + INSERT INTO species (code, name, active, created_at_utc, updated_at_utc) + VALUES ('goose', 'Goose', 1, 1704067200000, 1704067200000) + """ + ) + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO species (code, name, active, created_at_utc, updated_at_utc) + VALUES ('goose', 'Another Goose', 1, 1704067200000, 1704067200000) + """ + ) + + +class TestLocationsTable: + """Tests for locations table schema and constraints.""" + + def test_insert_valid_location(self, migrated_db): + """Can insert valid location data with 26-char ULID.""" + migrated_db.execute( + """ + INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAV', 'Strip 1', 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT id, name FROM locations WHERE name='Strip 1'" + ).fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "Strip 1") + + def test_id_length_check_constraint(self, migrated_db): + """Location ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc) + VALUES ('short', 'Bad Location', 1, 1704067200000, 1704067200000) + """ + ) + + def test_name_unique_constraint(self, migrated_db): + """Location name must be unique.""" + migrated_db.execute( + """ + INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAV', 'Strip 1', 1, 1704067200000, 1704067200000) + """ + ) + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAW', 'Strip 1', 1, 1704067200000, 1704067200000) + """ + ) + + +class TestProductsTable: + """Tests for products table schema and constraints.""" + + def test_insert_valid_product_piece(self, migrated_db): + """Can insert valid product with piece unit.""" + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, active, created_at_utc, updated_at_utc) + VALUES ('egg.duck', 'Duck Egg', 'piece', 1, 1, 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT code, unit FROM products WHERE code='egg.duck'" + ).fetchone() + assert result == ("egg.duck", "piece") + + def test_insert_valid_product_kg(self, migrated_db): + """Can insert valid product with kg unit.""" + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, active, created_at_utc, updated_at_utc) + VALUES ('meat', 'Meat', 'kg', 1, 1, 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT unit FROM products WHERE code='meat'").fetchone() + assert result[0] == "kg" + + def test_unit_check_constraint(self, migrated_db): + """Product unit must be 'piece' or 'kg'.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, active, created_at_utc, updated_at_utc) + VALUES ('bad', 'Bad', 'liter', 1, 1, 1, 1704067200000, 1704067200000) + """ + ) + + def test_collectable_check_constraint(self, migrated_db): + """Product collectable must be 0 or 1.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, active, created_at_utc, updated_at_utc) + VALUES ('bad', 'Bad', 'piece', 5, 1, 1, 1704067200000, 1704067200000) + """ + ) + + def test_sellable_check_constraint(self, migrated_db): + """Product sellable must be 0 or 1.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, active, created_at_utc, updated_at_utc) + VALUES ('bad', 'Bad', 'piece', 1, -1, 1, 1704067200000, 1704067200000) + """ + ) + + +class TestFeedTypesTable: + """Tests for feed_types table schema and constraints.""" + + def test_insert_valid_feed_type(self, migrated_db): + """Can insert valid feed type with protein percentage.""" + migrated_db.execute( + """ + INSERT INTO feed_types (code, name, default_bag_size_kg, protein_pct, active, created_at_utc, updated_at_utc) + VALUES ('layer', 'Layer Feed', 20, 16.5, 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT code, default_bag_size_kg, protein_pct FROM feed_types WHERE code='layer'" + ).fetchone() + assert result == ("layer", 20, 16.5) + + def test_insert_feed_type_without_protein(self, migrated_db): + """Can insert feed type with NULL protein percentage.""" + migrated_db.execute( + """ + INSERT INTO feed_types (code, name, default_bag_size_kg, protein_pct, active, created_at_utc, updated_at_utc) + VALUES ('starter', 'Starter Feed', 25, NULL, 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT protein_pct FROM feed_types WHERE code='starter'" + ).fetchone() + assert result[0] is None + + def test_bag_size_check_constraint(self, migrated_db): + """Feed type default_bag_size_kg must be >= 1.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO feed_types (code, name, default_bag_size_kg, protein_pct, active, created_at_utc, updated_at_utc) + VALUES ('bad', 'Bad Feed', 0, NULL, 1, 1704067200000, 1704067200000) + """ + ) + + +class TestUsersTable: + """Tests for users table schema and constraints.""" + + def test_insert_valid_admin_user(self, migrated_db): + """Can insert valid admin user.""" + migrated_db.execute( + """ + INSERT INTO users (username, role, active, created_at_utc, updated_at_utc) + VALUES ('ppetru', 'admin', 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT username, role FROM users WHERE username='ppetru'" + ).fetchone() + assert result == ("ppetru", "admin") + + def test_insert_valid_recorder_user(self, migrated_db): + """Can insert valid recorder user.""" + migrated_db.execute( + """ + INSERT INTO users (username, role, active, created_at_utc, updated_at_utc) + VALUES ('ines', 'recorder', 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT role FROM users WHERE username='ines'").fetchone() + assert result[0] == "recorder" + + def test_role_check_constraint(self, migrated_db): + """User role must be 'admin' or 'recorder'.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO users (username, role, active, created_at_utc, updated_at_utc) + VALUES ('bad', 'superuser', 1, 1704067200000, 1704067200000) + """ + ) + + def test_username_is_primary_key(self, migrated_db): + """Username is primary key (duplicate rejected).""" + migrated_db.execute( + """ + INSERT INTO users (username, role, active, created_at_utc, updated_at_utc) + VALUES ('guest', 'recorder', 1, 1704067200000, 1704067200000) + """ + ) + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO users (username, role, active, created_at_utc, updated_at_utc) + VALUES ('guest', 'admin', 1, 1704067200000, 1704067200000) + """ + ) + + +class TestDefaultValues: + """Tests for default values in tables.""" + + def test_species_active_defaults_to_1(self, migrated_db): + """Species active defaults to 1 when not specified.""" + migrated_db.execute( + """ + INSERT INTO species (code, name, created_at_utc, updated_at_utc) + VALUES ('sheep', 'Sheep', 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT active FROM species WHERE code='sheep'").fetchone() + assert result[0] == 1 + + def test_locations_active_defaults_to_1(self, migrated_db): + """Locations active defaults to 1 when not specified.""" + migrated_db.execute( + """ + INSERT INTO locations (id, name, created_at_utc, updated_at_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAV', 'Nursery 1', 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute( + "SELECT active FROM locations WHERE name='Nursery 1'" + ).fetchone() + assert result[0] == 1 + + def test_products_active_defaults_to_1(self, migrated_db): + """Products active defaults to 1 when not specified.""" + migrated_db.execute( + """ + INSERT INTO products (code, name, unit, collectable, sellable, created_at_utc, updated_at_utc) + VALUES ('offal', 'Offal', 'kg', 1, 1, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT active FROM products WHERE code='offal'").fetchone() + assert result[0] == 1 + + def test_feed_types_active_defaults_to_1(self, migrated_db): + """Feed types active defaults to 1 when not specified.""" + migrated_db.execute( + """ + INSERT INTO feed_types (code, name, default_bag_size_kg, created_at_utc, updated_at_utc) + VALUES ('grower', 'Grower Feed', 20, 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT active FROM feed_types WHERE code='grower'").fetchone() + assert result[0] == 1 + + def test_users_active_defaults_to_1(self, migrated_db): + """Users active defaults to 1 when not specified.""" + migrated_db.execute( + """ + INSERT INTO users (username, role, created_at_utc, updated_at_utc) + VALUES ('newuser', 'recorder', 1704067200000, 1704067200000) + """ + ) + result = migrated_db.execute("SELECT active FROM users WHERE username='newuser'").fetchone() + assert result[0] == 1 diff --git a/tests/test_models_reference.py b/tests/test_models_reference.py new file mode 100644 index 0000000..d742daa --- /dev/null +++ b/tests/test_models_reference.py @@ -0,0 +1,405 @@ +# 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