diff --git a/README.md b/README.md index bc2cc45..0ffae92 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,4 @@ python -m http.server --directory coverage_html 8000 ``` → open **http://localhost:8000** -![Coverage Report](./docs/test-screenshots/files_test.png) -![Coverage Report](./docs/test-screenshots/functions_test.png) -![Coverage Report](./docs/test-screenshots/classes_test.png) \ No newline at end of file +![Coverage Report](./docs/test-screenshots/api-coverage-test-report.png) \ No newline at end of file diff --git a/docs/test-screenshots/api-coverage-test-report.png b/docs/test-screenshots/api-coverage-test-report.png new file mode 100644 index 0000000..0b09c6a Binary files /dev/null and b/docs/test-screenshots/api-coverage-test-report.png differ diff --git a/docs/test-screenshots/classes_test.png b/docs/test-screenshots/classes_test.png deleted file mode 100644 index 53c4caf..0000000 Binary files a/docs/test-screenshots/classes_test.png and /dev/null differ diff --git a/docs/test-screenshots/files_test.png b/docs/test-screenshots/files_test.png deleted file mode 100644 index 97dee9e..0000000 Binary files a/docs/test-screenshots/files_test.png and /dev/null differ diff --git a/docs/test-screenshots/functions_test.png b/docs/test-screenshots/functions_test.png deleted file mode 100644 index c565a04..0000000 Binary files a/docs/test-screenshots/functions_test.png and /dev/null differ diff --git a/entities/migrations/0003_entity_unit_price.py b/entities/migrations/0003_entity_unit_price.py index 55ef7b0..ef08046 100644 --- a/entities/migrations/0003_entity_unit_price.py +++ b/entities/migrations/0003_entity_unit_price.py @@ -5,20 +5,32 @@ def forwards(apps, schema_editor): Entity = apps.get_model('entities', 'Entity') for obj in Entity.objects.all(): - # convert existing price_cents (int) to unit_price Decimal - cents = getattr(obj, 'price_cents', None) - if cents is not None: - obj.unit_price = Decimal(cents) / Decimal('100') + # convert existing price_cents (int) to unit_price Decimal (defensive) + try: + cents = getattr(obj, 'price_cents', None) + if cents is None: + continue + # handle strings/decimals/ints + d = Decimal(str(cents)) + obj.unit_price = (d / Decimal('100')).quantize(Decimal('0.0001')) obj.save(update_fields=['unit_price']) + except Exception: + # skip problematic rows but continue migration + continue def backwards(apps, schema_editor): Entity = apps.get_model('entities', 'Entity') for obj in Entity.objects.all(): - val = getattr(obj, 'unit_price', None) - if val is not None: - obj.price_cents = int((Decimal(val) * Decimal('100')).to_integral_value()) + try: + val = getattr(obj, 'unit_price', None) + if val is None: + continue + d = Decimal(str(val)) + obj.price_cents = int((d * Decimal('100')).to_integral_value()) obj.save(update_fields=['price_cents']) + except Exception: + continue class Migration(migrations.Migration): @@ -31,7 +43,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name='entity', name='unit_price', - field=models.DecimalField(decimal_places=4, default=Decimal('0.0000'), max_digits=12), + # use a serializable default (string) in migration to avoid import-time issues + field=models.DecimalField(decimal_places=4, default='0.0000', max_digits=12), ), migrations.RunPython(forwards, backwards), migrations.RemoveField( diff --git a/entities/models.py b/entities/models.py index a6c79fb..eac38e0 100644 --- a/entities/models.py +++ b/entities/models.py @@ -13,11 +13,25 @@ class Entity(models.Model): def __str__(self): return f"{self.type}: {self.name or self.pk}" + @property + def unit_price_decimal(self) -> Decimal: + """Return the stored unit_price as Decimal.""" + # ensure we always return a Decimal instance + val = self.unit_price + try: + return Decimal(val) + except Exception: + return Decimal(str(val or '0')) + + @property def unit_price_float(self) -> float: - """Return unit price as float for convenience (not used for storage).""" - return float(self.unit_price or Decimal('0')) + """Return the stored unit_price as float (convenience only).""" + return float(self.unit_price_decimal) def price_cents_truncated(self) -> int: - """Return price in cents after truncating to 2 decimal places (no rounding).""" - val = (Decimal(self.unit_price or Decimal('0'))).quantize(Decimal('0.01'), rounding=ROUND_DOWN) + """Return price in cents after truncating to 2 decimal places (no rounding). + + Example: unit_price = Decimal('1.8640') -> truncates to 1.86 -> returns 186 + """ + val = self.unit_price_decimal.quantize(Decimal('0.01'), rounding=ROUND_DOWN) return int((val * 100).to_integral_value()) \ No newline at end of file diff --git a/entities/services.py b/entities/services.py index 4acef86..8becc54 100644 --- a/entities/services.py +++ b/entities/services.py @@ -20,7 +20,10 @@ def list_entities( } def apply(qs, field, value, op): - if not value: + if value is None: + return qs + # treat empty string as no-filter + if isinstance(value, str) and value.strip() == "": return qs if op == "notcontains": return qs.exclude(**{f"{field}__icontains": value}) @@ -40,13 +43,17 @@ def create_entity(data: Dict[str, Any]) -> Entity: unit = data.pop('unit_price', None) if unit is not None: # accept both 50.99 and '50,99' by normalizing comma to dot - unit_str = str(unit).replace(',', '.') - try: - d = Decimal(unit_str) - except (InvalidOperation, ValueError): - d = Decimal(float(unit_str)) - # store with 4 decimal places to preserve measurement precision, truncate (ROUND_DOWN) - data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN) + unit_str = str(unit).replace(',', '.').strip() + # treat empty string as no-value + if unit_str == "": + unit = None + else: + try: + d = Decimal(unit_str) + except (InvalidOperation, ValueError): + d = Decimal(float(unit_str)) + # store with 4 decimal places to preserve measurement precision, truncate (ROUND_DOWN) + data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN) with transaction.atomic(): return Entity.objects.create(**data) @@ -55,12 +62,15 @@ def update_entity(entity_id: int, data: Dict[str, Any]) -> Entity: # support updating via `unit_price` as well unit = data.pop('unit_price', None) if unit is not None: - unit_str = str(unit).replace(',', '.') - try: - d = Decimal(unit_str) - except (InvalidOperation, ValueError): - d = Decimal(float(unit_str)) - data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN) + unit_str = str(unit).replace(',', '.').strip() + if unit_str == "": + unit = None + else: + try: + d = Decimal(unit_str) + except (InvalidOperation, ValueError): + d = Decimal(float(unit_str)) + data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN) obj = Entity.objects.get(pk=entity_id) for k, v in data.items(): diff --git a/tests/test_exhaustive_branches.py b/tests/test_exhaustive_branches.py new file mode 100644 index 0000000..1f0c44e --- /dev/null +++ b/tests/test_exhaustive_branches.py @@ -0,0 +1,83 @@ +import pytest +from decimal import Decimal + +from entities import services +from entities.models import Entity + + +@pytest.mark.django_db +def test_model_helpers_direct_access(): + obj = services.create_entity({"type": "test", "unit_price": "1.864"}) + # access Decimal property + dec = obj.unit_price_decimal + assert isinstance(dec, Decimal) + # access float helper + f = obj.unit_price_float + assert isinstance(f, float) + # truncated cents + cents = obj.price_cents_truncated() + assert isinstance(cents, int) + assert cents == 186 + + +@pytest.mark.django_db +def test_services_create_update_variants_cover_branches(): + # integers and floats + e1 = services.create_entity({"type": "v", "unit_price": 50}) + e2 = services.create_entity({"type": "v", "unit_price": 50.0}) + e3 = services.create_entity({"type": "v", "unit_price": "50.50"}) + e4 = services.create_entity({"type": "v", "unit_price": "50,50"}) + + assert e1.unit_price == Decimal("50.0000") + assert e2.unit_price == Decimal("50.0000") + assert e3.unit_price == Decimal("50.5000") + assert e4.unit_price == Decimal("50.5000") + + # update with different forms + updated = services.update_entity(e1.id, {"unit_price": "10,12345"}) + assert updated.unit_price == Decimal("10.1234") + + # update with empty string should not change + before = e2.unit_price + updated2 = services.update_entity(e2.id, {"unit_price": ""}) + assert updated2.unit_price == before + + # update with None should not change + updated3 = services.update_entity(e3.id, {"unit_price": None}) + assert updated3.unit_price == e3.unit_price + + +@pytest.mark.django_db +def test_list_entities_all_ops_exercised(): + # prepare data + for e in list(services.list_entities()): + e.delete() + + services.create_entity({"type": "tool", "name": "hammer", "description": "heavy tool"}) + services.create_entity({"type": "toolbox", "name": "box", "description": "container"}) + services.create_entity({"type": "widget", "name": "w1", "description": "blue widget"}) + + # contains (default) + r1 = services.list_entities(name="amm") + assert any(getattr(x, 'name', '') == 'hammer' for x in r1) + + # startswith + r2 = services.list_entities(name="ham", name_op="startswith") + assert any(getattr(x, 'name', '') == 'hammer' for x in r2) + + # equals on type + r3 = services.list_entities(type="tool", type_op="equals") + assert all(x.type == "tool" for x in r3) + + # endswith on description + r4 = services.list_entities(description="widget", description_op="endswith") + assert any((x.description or '').endswith("widget") for x in r4) + + # notcontains + r5 = services.list_entities(name="box", name_op="notcontains") + assert all((x.name is None) or ("box" not in x.name) for x in r5) + + # empty string and None filters + all_none = services.list_entities(name=None) + all_empty = services.list_entities(name="") + assert all_none.count() == all_empty.count() diff --git a/tests/test_migration_and_excepts.py b/tests/test_migration_and_excepts.py new file mode 100644 index 0000000..10673bd --- /dev/null +++ b/tests/test_migration_and_excepts.py @@ -0,0 +1,102 @@ +import importlib +from decimal import Decimal + +import pytest + +from entities import services +from entities.models import Entity + + +@pytest.mark.django_db +def test_create_entity_forced_decimal_invalid_monkeypatch(monkeypatch): + # Force Decimal(...) to raise for the specific string so except branch is used + orig_D = services.Decimal + + def fake_D(x): + # raise InvalidOperation when called with that exact string + if isinstance(x, str) and x == "50.99": + raise services.InvalidOperation + return orig_D(x) + + monkeypatch.setattr(services, 'Decimal', fake_D) + + obj = services.create_entity({"type": "x", "unit_price": "50.99"}) + # despite fake raising on first call, float conversion path should succeed + assert obj.unit_price == Decimal('50.9900') + + +@pytest.mark.django_db +def test_update_entity_forced_decimal_invalid(monkeypatch): + orig_D = services.Decimal + + def fake_D(x): + if isinstance(x, str) and x == "10.12345": + raise services.InvalidOperation + return orig_D(x) + + obj = services.create_entity({"type": "u"}) + monkeypatch.setattr(services, 'Decimal', fake_D) + updated = services.update_entity(obj.id, {"unit_price": "10.12345"}) + assert updated.unit_price == Decimal('10.1234') + + +def _make_fake_objs(vals, attr_name): + class Obj: + def __init__(self, v): + setattr(self, attr_name, v) + + def save(self, update_fields=None): + # pretend to save + pass + + return [Obj(v) for v in vals] + + +def test_migration_runpython_forwards_and_backwards(): + mod = importlib.import_module('entities.migrations.0003_entity_unit_price') + + class FakeManager: + def __init__(self, objs): + self._objs = objs + + def all(self): + return list(self._objs) + + class FakeModel: + def __init__(self, objs): + self.objects = FakeManager(objs) + + class Apps: + def __init__(self, model): + self._model = model + + def get_model(self, app, name): + return self._model + + # create fake objects with various price_cents + objs = _make_fake_objs([100, '200', None, 'bad'], 'price_cents') + fake_model = FakeModel(objs) + apps = Apps(fake_model) + + # forwards should run and skip problematic rows + mod.forwards(apps, None) + + # now test backwards with unit_price values + objs2 = _make_fake_objs([Decimal('1.23'), '4.56', None, 'bad'], 'unit_price') + fake_model2 = FakeModel(objs2) + apps2 = Apps(fake_model2) + + mod.backwards(apps2, None) + + +@pytest.mark.django_db +def test_model_unit_price_decimal_variants(): + e = services.create_entity({"type": "p", "unit_price": "3.1415"}) + assert e.unit_price_decimal == Decimal('3.1415') + + # force string stored in DB (simulate weird value) + e.unit_price = '2.5' + assert e.unit_price_decimal == Decimal('2.5') + + e.unit_price = None + assert e.unit_price_decimal == Decimal('0') diff --git a/tests/test_price_and_filters.py b/tests/test_price_and_filters.py new file mode 100644 index 0000000..222ea05 --- /dev/null +++ b/tests/test_price_and_filters.py @@ -0,0 +1,66 @@ +import pytest +from decimal import Decimal + +from entities import services +from entities.models import Entity + + +@pytest.mark.django_db +def test_truncation_and_sum(): + e1 = services.create_entity({"type": "fruit", "unit_price": "1.864"}) + e2 = services.create_entity({"type": "fruit", "unit_price": "1.134"}) + + assert isinstance(e1, Entity) + assert isinstance(e2, Entity) + + assert e1.price_cents_truncated() == 186 + assert e2.price_cents_truncated() == 113 + + total_cents = e1.price_cents_truncated() + e2.price_cents_truncated() + assert total_cents == 299 + assert total_cents / 100 == 2.99 + + +@pytest.mark.django_db +def test_create_accepts_comma_and_dot(): + a = services.create_entity({"type": "veh", "unit_price": "50,99"}) + b = services.create_entity({"type": "veh", "unit_price": 50.99}) + + assert a.unit_price == Decimal("50.9900") + assert b.unit_price == Decimal("50.9900") + + +@pytest.mark.django_db +def test_update_sets_unit_price(): + obj = services.create_entity({"type": "t"}) + updated = services.update_entity(obj.id, {"unit_price": "10,12345"}) + # quantized to 4 decimals with ROUND_DOWN -> 10.1234 + assert updated.unit_price == Decimal("10.1234") + + +@pytest.mark.django_db +def test_list_entities_operators(): + # create sample data + services.create_entity({"type": "tool", "name": "hammer", "description": "heavy tool"}) + services.create_entity({"type": "toolbox", "name": "box", "description": "container"}) + services.create_entity({"type": "widget", "name": "w1", "description": "blue widget"}) + + # contains (default) + res = services.list_entities(name="amm") + assert any(r.name == "hammer" for r in res) + + # startswith + res2 = services.list_entities(name="ham", name_op="startswith") + assert any(r.name == "hammer" for r in res2) + + # equals on type + res3 = services.list_entities(type="tool", type_op="equals") + assert all(r.type == "tool" for r in res3) + + # endswith on description + res4 = services.list_entities(description="widget", description_op="endswith") + assert any(r.description.endswith("widget") for r in res4) + + # notcontains exclude names with 'box' + res5 = services.list_entities(name="box", name_op="notcontains") + assert all("box" not in (r.name or "") for r in res5) diff --git a/tests/test_services_branches.py b/tests/test_services_branches.py new file mode 100644 index 0000000..2236ef1 --- /dev/null +++ b/tests/test_services_branches.py @@ -0,0 +1,59 @@ +import pytest +from decimal import Decimal + +from entities import services +from entities.models import Entity + + +@pytest.mark.django_db +def test_create_without_unit_price_defaults_to_zero(): + obj = services.create_entity({"type": "no_price"}) + assert isinstance(obj, Entity) + assert obj.unit_price == Decimal("0.0000") + + +@pytest.mark.django_db +def test_create_accepts_int_float_and_strings(): + a = services.create_entity({"type": "a", "unit_price": 50}) + b = services.create_entity({"type": "b", "unit_price": 50.0}) + c = services.create_entity({"type": "c", "unit_price": "50.99"}) + d = services.create_entity({"type": "d", "unit_price": "50,99"}) + + assert a.unit_price == Decimal("50.0000") + assert b.unit_price == Decimal("50.0000") + assert c.unit_price == Decimal("50.9900") + assert d.unit_price == Decimal("50.9900") + + +@pytest.mark.django_db +def test_update_with_none_or_empty_does_not_change(): + obj = services.create_entity({"type": "u", "unit_price": "10.50"}) + before = obj.unit_price + updated = services.update_entity(obj.id, {"unit_price": None}) + assert updated.unit_price == before + + updated2 = services.update_entity(obj.id, {"unit_price": ""}) + assert updated2.unit_price == before + + +@pytest.mark.django_db +def test_list_entities_none_and_empty_and_notcontains(): + # ensure clean state + for e in list(services.list_entities()): + e.delete() + + services.create_entity({"type": "tool", "name": "hammer", "description": "heavy tool"}) + services.create_entity({"type": "toolbox", "name": "box", "description": "container"}) + + # None should behave like no filter + all1 = services.list_entities(name=None) + all2 = services.list_entities() + assert all1.count() == all2.count() + + # empty string should behave like no filter + all3 = services.list_entities(name="") + assert all3.count() == all2.count() + + # notcontains should exclude + res = services.list_entities(name="box", name_op="notcontains") + assert all((r.name is None or "box" not in r.name) for r in res)