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>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
# 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
|