268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
|
|
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
|