from __future__ import annotations import sqlite3 from pathlib import Path from types import SimpleNamespace import pytest from peewee import IntegrityError, DoesNotExist from funkyjuicerecipes.data.db import init_database, DB_FILENAME from funkyjuicerecipes.data.dao.flavors import FlavorRepository from funkyjuicerecipes.data.dao.recipes import RecipeRepository from funkyjuicerecipes.data.models.flavor import Flavor from funkyjuicerecipes.data.models.recipe_flavor import RecipeFlavor from funkyjuicerecipes.data.models.flavor import Flavor as FlavorModel class DummyPaths(SimpleNamespace): @property def data(self) -> Path: # type: ignore[override] return self._data @data.setter def data(self, value: Path) -> None: self._data = Path(value) class DummyApp(SimpleNamespace): def __init__(self, data_dir: Path) -> None: super().__init__() self.paths = DummyPaths() self.paths.data = data_dir def _init_db(tmp_path: Path): app = DummyApp(tmp_path / "data") db = init_database(app) return app, db def test_flavor_uniqueness_nocase(tmp_path: Path): app, db = _init_db(tmp_path) flavors = FlavorRepository() f1 = flavors.create(name="Strawberry", company="TPA", base="PG") assert f1.id is not None with pytest.raises(IntegrityError): flavors.create(name="strawberry", company="tpa", base="PG") def test_flavor_soft_delete_and_listing_filters(tmp_path: Path): app, db = _init_db(tmp_path) flavors = FlavorRepository() f1 = flavors.create(name="Vanilla", company="CAP", base="PG") f2 = flavors.create(name="Cookie", company="FA", base="PG") # default list excludes deleted rows = flavors.list() assert [f.name for f in rows] == ["Cookie", "Vanilla"] or [f.name for f in rows] == ["Vanilla", "Cookie"] # soft delete f2 assert flavors.soft_delete(f2.id) is True rows_default = flavors.list() assert all(not r.is_deleted for r in rows_default) assert {r.id for r in rows_default} == {f1.id} rows_only_deleted = flavors.list(only_deleted=True) assert {r.id for r in rows_only_deleted} == {f2.id} rows_include_deleted = flavors.list(include_deleted=True) assert {r.id for r in rows_include_deleted} == {f1.id, f2.id} # undelete brings it back to default list assert flavors.undelete(f2.id) is True rows_default2 = flavors.list() assert {r.id for r in rows_default2} == {f1.id, f2.id} # hard delete safety: referenced flavor cannot be deleted recipes = RecipeRepository() r = recipes.create( name="Test Mix", size_ml=30, base_pg_pct=50, base_vg_pct=50, nic_target_mg_per_ml=0, nic_base="PG", ingredients=[(f1.id, 5.0)], ) # now f1 is referenced; hard delete should be blocked assert flavors.hard_delete_when_safe(f1.id) is False # f2 is not referenced; can be hard-deleted assert flavors.hard_delete_when_safe(f2.id) is True with pytest.raises(DoesNotExist): Flavor.get_by_id(f2.id) def test_recipe_creation_and_get_with_ingredients_includes_soft_deleted_flavors(tmp_path: Path): app, db = _init_db(tmp_path) flavors = FlavorRepository() recipes = RecipeRepository() fa = flavors.create(name="Apple", company="FA", base="PG") fb = flavors.create(name="Berry", company="CAP", base="VG") # Soft delete one flavor flavors.soft_delete(fb.id) r = recipes.create( name="Fruit", size_ml=60, base_pg_pct=70, base_vg_pct=30, nic_target_mg_per_ml=3, nic_base="PG", ingredients=[(fa.id, 4.0), (fb.id, 2.0)], ) r2, pairs = recipes.get_with_ingredients(r.id) assert r2.id == r.id # Both flavors should be present, including the soft-deleted one got = {fl.id: pct for (fl, pct) in pairs} assert got == {fa.id: 4.0, fb.id: 2.0} # Verify the deleted flag is visible on the soft-deleted flavor deleted_flavor = next(fl for (fl, pct) in pairs if fl.id == fb.id) assert deleted_flavor.is_deleted is True def test_replace_ingredients_diff_behavior(tmp_path: Path): app, db = _init_db(tmp_path) flavors = FlavorRepository() recipes = RecipeRepository() f1 = flavors.create(name="Lemon", company="FA", base="PG") f2 = flavors.create(name="Lime", company="FA", base="PG") f3 = flavors.create(name="Orange", company="FA", base="PG") r = recipes.create( name="Citrus", size_ml=30, base_pg_pct=50, base_vg_pct=50, nic_target_mg_per_ml=0, nic_base="PG", ingredients=[(f1.id, 3.0), (f2.id, 2.0)], ) # Capture initial rows rf_rows = list( RecipeFlavor.select().where(RecipeFlavor.recipe == r.id).order_by(RecipeFlavor.id) ) assert len(rf_rows) == 2 rf_f1 = next(x for x in rf_rows if x.flavor_id == f1.id) rf_f2 = next(x for x in rf_rows if x.flavor_id == f2.id) # Now: remove f1, update f2 pct, add f3 recipes.replace_ingredients(r.id, [(f2.id, 5.5), (f3.id, 1.0)]) rf_rows_after = list( RecipeFlavor.select().where(RecipeFlavor.recipe == r.id).order_by(RecipeFlavor.id) ) assert len(rf_rows_after) == 2 flavors_after = {x.flavor_id for x in rf_rows_after} assert flavors_after == {f2.id, f3.id} # f2 row should be updated in-place (same id) with new pct rf_f2_after = next(x for x in rf_rows_after if x.flavor_id == f2.id) assert rf_f2_after.id == rf_f2.id assert pytest.approx(rf_f2_after.pct, rel=1e-9) == 5.5 # f1 row should be gone assert not RecipeFlavor.select().where( (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f1.id) ).exists() # f3 should be newly present assert RecipeFlavor.select().where( (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id) ).exists() def test_flavor_snapshots_populated_and_stable(tmp_path: Path): app, db = _init_db(tmp_path) flavors = FlavorRepository() recipes = RecipeRepository() # Create a flavor and recipe using it f = flavors.create(name="Vanilla", company="CAP", base="PG") r = recipes.create( name="Vanilla Mix", size_ml=30, base_pg_pct=50, base_vg_pct=50, nic_target_mg_per_ml=0, nic_base="PG", ingredients=[(f.id, 5.0)], ) rf = RecipeFlavor.get(RecipeFlavor.recipe == r.id) assert rf.flavor_id == f.id # Snapshots should be populated at creation time assert rf.flavor_name_snapshot == "Vanilla" assert rf.flavor_company_snapshot == "CAP" assert rf.flavor_base_snapshot == "PG" # Change the live Flavor fields flavors.update(f.id, name="Vanilla Bean", company="Capella", base="VG") # Snapshots must remain unchanged rf2 = RecipeFlavor.get_by_id(rf.id) assert rf2.flavor_name_snapshot == "Vanilla" assert rf2.flavor_company_snapshot == "CAP" assert rf2.flavor_base_snapshot == "PG" # Pct-only update should not touch snapshots recipes.replace_ingredients(r.id, [(f.id, 7.5)]) rf3 = RecipeFlavor.get(RecipeFlavor.recipe == r.id) assert rf3.pct == 7.5 assert rf3.flavor_name_snapshot == "Vanilla" assert rf3.flavor_company_snapshot == "CAP" assert rf3.flavor_base_snapshot == "PG" # Replacing with a different flavor should capture new snapshots f2 = flavors.create(name="Cookie", company="FA", base="VG") recipes.replace_ingredients(r.id, [(f2.id, 2.0)]) rf_after = RecipeFlavor.get(RecipeFlavor.recipe == r.id) assert rf_after.flavor_id == f2.id assert rf_after.pct == 2.0 assert rf_after.flavor_name_snapshot == "Cookie" assert rf_after.flavor_company_snapshot == "FA" assert rf_after.flavor_base_snapshot == "VG"