import json from datetime import date, timedelta import pytest from app import journal from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF def actions(vault): return [e.action for e in vault.doc.journal] def last(vault): return vault.doc.journal[-1] def changed(entry): return {c.field: (c.old, c.new) for c in entry.changes} # --- запись вообще происходит --------------------------------------------- def test_create_product_is_logged(vault): journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35) entry = last(vault) assert entry.action == "product.create" assert entry.label == "Булка с маком" assert changed(entry)["себестоимость"][1] == "20,00 ₽" def test_price_change_records_old_and_new(vault, buns): journal.change_prices(vault, buns["мак"].id, cost_price=25, retail_price=40) entry = last(vault) assert entry.action == "product.price_change" assert changed(entry)["себестоимость"] == ("20,00 ₽", "25,00 ₽") assert changed(entry)["цена продажи"] == ("35,00 ₽", "40,00 ₽") def test_price_change_appends_to_history(vault, buns): product = buns["мак"] assert len(product.price_history) == 1 # точка при создании journal.change_prices(vault, product.id, cost_price=25, retail_price=40) assert len(product.price_history) == 2 assert product.price_history[-1].cost_price == 25 def test_every_operation_touches_the_vault(vault, buns, today, in_two_weeks): """revision растёт на каждой мутации — на него опирается автосохранение.""" before = vault.revision journal.create_batch( vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] ) assert vault.revision > before def test_journal_covers_the_full_lifecycle(vault, buns, today, in_two_weeks): batch = journal.create_batch( vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] ) sale = journal.create_sale( vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}], paid_amount=0, ) journal.add_sale_payment(vault, sale.id, 70) journal.add_bakery_payment(vault, 200, batch_id=batch.id) journal.delete_sale(vault, sale.id) assert actions(vault)[-5:] == [ "batch.create", "sale.create", "sale.payment_add", "bakery_payment.add", "sale.delete", ] # --- пустые правки не создают шума ---------------------------------------- def test_noop_update_writes_nothing(vault, buns): count = len(vault.doc.journal) journal.update_product(vault, buns["мак"].id, name="Булка с маком") assert len(vault.doc.journal) == count def test_noop_price_change_writes_nothing(vault, buns): count = len(vault.doc.journal) journal.change_prices(vault, buns["мак"].id, cost_price=20, retail_price=35) assert len(vault.doc.journal) == count assert len(buns["мак"].price_history) == 1 def test_noop_edit_leaves_vault_clean(vault, buns): """Иначе открыл-закрыл форму — и в git уехал пустой коммит.""" vault.save() journal.update_product(vault, buns["мак"].id, note="") assert vault.is_dirty is False # --- секреты не утекают в журнал ------------------------------------------ def test_token_value_never_reaches_the_journal(vault): secret = "glpat-СУПЕРСЕКРЕТ12345" journal.update_settings(vault, git={"token": secret}) entry = last(vault) assert entry.action == "settings.update" assert changed(entry)["git.token"] == ("скрыто", "изменён") # Токен обязан быть в настройках, но нигде в журнале — включая открытый экспорт. assert vault.doc.settings.git.token == secret dumped = json.dumps([e.to_dict() for e in vault.doc.journal], ensure_ascii=False) assert secret not in dumped def test_non_secret_git_settings_are_logged_plainly(vault): journal.update_settings(vault, git={"remote_url": "https://gt.ser.gay/kizya/food-market.git"}) assert "gt.ser.gay" in changed(last(vault))["git.remote_url"][1] def test_settings_keep_types(vault): journal.update_settings(vault, git={"auto_push_minutes": "30", "enabled": False}) assert vault.doc.settings.git.auto_push_minutes == 30 assert vault.doc.settings.git.enabled is False # --- проверки корректности ------------------------------------------------ def test_product_in_use_cannot_be_deleted(vault, buns, today, in_two_weeks): journal.create_batch( vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] ) with pytest.raises(journal.ValidationError, match="архив"): journal.delete_product(vault, buns["мак"].id) def test_unused_product_can_be_deleted(vault, buns): journal.delete_product(vault, buns["мак"].id) assert vault.doc.product(buns["мак"].id) is None assert last(vault).action == "product.delete" def test_batch_due_date_must_not_precede_purchase(vault, buns, today): with pytest.raises(journal.ValidationError, match="раньше"): journal.create_batch( vault, today, today - timedelta(days=1), [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}], ) def test_empty_batch_rejected(vault, today, in_two_weeks): with pytest.raises(journal.ValidationError, match="хотя бы одна"): journal.create_batch(vault, today, in_two_weeks, []) def test_zero_quantity_rejected(vault, buns, today, in_two_weeks): with pytest.raises(journal.ValidationError, match="больше нуля"): journal.create_batch( vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 0, "unit_cost": 20}] ) def test_counterparty_with_sales_cannot_be_deleted(vault, buns, today): vasya = journal.create_counterparty(vault, "Вася") journal.create_sale( vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 20}], counterparty_id=vasya.id, ) with pytest.raises(journal.ValidationError, match="удалить нельзя"): journal.delete_counterparty(vault, vasya.id) def test_batch_numbers_increment(vault, buns, today, in_two_weeks): lines = [{"product_id": buns["мак"].id, "qty": 1, "unit_cost": 20}] first = journal.create_batch(vault, today, in_two_weeks, lines) second = journal.create_batch(vault, today, in_two_weeks, lines) assert (first.number, second.number) == (1, 2) # --- поведение вокруг оплат ----------------------------------------------- def test_sale_is_paid_in_full_by_default(vault, buns, today): sale = journal.create_sale( vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}] ) assert sale.paid == sale.total == 70 assert sale.debt == 0 def test_sale_on_credit_creates_debt(vault, buns, today): sale = journal.create_sale( vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}], paid_amount=0, ) assert sale.paid == 0 assert sale.debt == 100 journal.add_sale_payment(vault, sale.id, 60) assert sale.debt == 40 def test_consumption_never_records_payment(vault, buns, today): """Съел сам — денег нет по определению, даже если форма что-то прислала.""" sale = journal.create_sale( vault, today, KIND_SELF, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 0}], paid_amount=999, ) assert sale.payments == [] assert sale.debt == 0 def test_overpayment_does_not_create_negative_debt(vault, buns, today): sale = journal.create_sale( vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}], paid_amount=0, ) journal.add_sale_payment(vault, sale.id, 100) assert sale.debt == 0 # --- удаление партии не теряет деньги ------------------------------------- def test_deleting_batch_keeps_bakery_payments(vault, buns, today, in_two_weeks): """Деньги пекарне реально ушли — они не должны исчезнуть вместе с партией.""" batch = journal.create_batch( vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}] ) payment = journal.add_bakery_payment(vault, 200, batch_id=batch.id) journal.delete_batch(vault, batch.id) assert payment in vault.doc.bakery_payments assert payment.batch_id is None # --- обслуживание --------------------------------------------------------- def test_prune_journal(vault, buns): for entry in vault.doc.journal: entry.ts = "2020-01-01T00:00:00+03:00" removed = journal.prune_journal(vault, date(2024, 1, 1)) assert removed == 3 assert [e.action for e in vault.doc.journal] == ["journal.prune"] def test_prune_nothing_to_remove_is_silent(vault, buns): count = len(vault.doc.journal) assert journal.prune_journal(vault, date(2000, 1, 1)) == 0 assert len(vault.doc.journal) == count def test_journal_entries_survive_save_and_reload(vault, buns): from app.storage import Vault vault.save() reopened = Vault.open(vault.path, "pw") assert [e.action for e in reopened.doc.journal] == ["product.create"] * 3 assert reopened.doc.journal[0].host