2026-08-22 21:29:53 +07:00
|
|
|
|
import json
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from app import crypto, journal
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
from app.models import SCHEMA_VERSION, Document
|
2026-08-22 21:29:53 +07:00
|
|
|
|
from app.storage import Vault, content_hash, migrate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_and_reopen(tmp_path):
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
|
|
|
|
|
|
reopened = Vault.open(path, "pw")
|
|
|
|
|
|
assert [p.name for p in reopened.doc.products] == ["Булка с маком"]
|
|
|
|
|
|
assert reopened.doc.products[0].cost_price == Decimal("20.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_with_wrong_password(tmp_path):
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
Vault.create(path, "pw")
|
|
|
|
|
|
with pytest.raises(crypto.WrongPassword):
|
|
|
|
|
|
Vault.open(path, "nope")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_refuses_to_clobber(tmp_path):
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
Vault.create(path, "pw")
|
|
|
|
|
|
with pytest.raises(crypto.VaultError):
|
|
|
|
|
|
Vault.create(path, "other")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_skips_write_when_nothing_changed(tmp_path):
|
|
|
|
|
|
"""Главная защита от мусорных коммитов в git.
|
|
|
|
|
|
|
|
|
|
|
|
Шифртекст каждый раз новый, поэтому решение «писать или нет» принимается
|
|
|
|
|
|
по хешу открытых данных, а не по байтам файла.
|
|
|
|
|
|
"""
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
|
|
|
|
|
|
|
|
|
|
|
assert vault.save() is True
|
|
|
|
|
|
assert vault.save() is False
|
|
|
|
|
|
assert vault.save() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_detects_real_change(tmp_path):
|
|
|
|
|
|
vault = Vault.create(tmp_path / "vault.fmdb", "pw")
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
|
|
|
|
|
assert vault.is_dirty is True
|
|
|
|
|
|
assert vault.save() is True
|
|
|
|
|
|
assert vault.is_dirty is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_freshly_opened_vault_is_not_dirty(tmp_path):
|
|
|
|
|
|
"""Иначе каждое открытие программы порождало бы коммит на пустом месте."""
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
|
|
|
|
|
|
reopened = Vault.open(path, "pw")
|
|
|
|
|
|
assert reopened.is_dirty is False
|
|
|
|
|
|
assert reopened.save() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_content_hash_ignores_key_order():
|
|
|
|
|
|
assert content_hash({"a": 1, "b": 2}) == content_hash({"b": 2, "a": 1})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_change_password(tmp_path):
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "old")
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
|
|
|
|
|
|
vault.change_password("new")
|
|
|
|
|
|
with pytest.raises(crypto.WrongPassword):
|
|
|
|
|
|
Vault.open(path, "old")
|
|
|
|
|
|
assert Vault.open(path, "new").doc.products[0].name == "Булка"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_atomic_write_leaves_backup(tmp_path):
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price=10, retail_price=20)
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
|
|
|
|
|
|
assert path.with_suffix(".fmdb.bak").exists()
|
|
|
|
|
|
assert not path.with_suffix(".fmdb.tmp").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_decimals_survive_round_trip(tmp_path):
|
|
|
|
|
|
"""Деньги ходят через строки, поэтому копейки не должны разъезжаться."""
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
vault = Vault.create(path, "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка", cost_price="33.33", retail_price="66.67")
|
|
|
|
|
|
vault.save()
|
|
|
|
|
|
|
|
|
|
|
|
product = Vault.open(path, "pw").doc.products[0]
|
|
|
|
|
|
assert product.cost_price == Decimal("33.33")
|
|
|
|
|
|
assert product.retail_price == Decimal("66.67")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_export_plain_json(tmp_path):
|
|
|
|
|
|
vault = Vault.create(tmp_path / "vault.fmdb", "pw")
|
|
|
|
|
|
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
|
|
|
|
|
|
|
|
|
|
|
out = vault.export_plain_json(tmp_path / "export.json")
|
|
|
|
|
|
data = json.loads(out.read_text(encoding="utf-8"))
|
|
|
|
|
|
assert data["products"][0]["name"] == "Булка с маком"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migration_adds_journal():
|
|
|
|
|
|
raw = migrate({"schema_version": 0, "products": []})
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
assert raw["schema_version"] == SCHEMA_VERSION
|
2026-08-22 21:29:53 +07:00
|
|
|
|
assert raw["journal"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
def test_migration_adds_packaging_without_changing_behaviour():
|
|
|
|
|
|
"""Старая база должна открыться так, будто всё записано в базовых единицах."""
|
|
|
|
|
|
raw = migrate(
|
|
|
|
|
|
{
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"products": [{"id": "p_1", "name": "Печенье", "unit": "шт", "cost_price": "20"}],
|
|
|
|
|
|
"batches": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "b_1", "number": 1, "date": "2026-08-01", "due_date": "2026-08-15",
|
|
|
|
|
|
"lines": [{"product_id": "p_1", "qty": "10", "unit_cost": "20"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"sales": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "s_1", "date": "2026-08-02", "kind": "retail",
|
|
|
|
|
|
"lines": [{"product_id": "p_1", "qty": "3", "unit_price": "30"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert raw["schema_version"] == SCHEMA_VERSION
|
|
|
|
|
|
assert raw["products"][0]["packs"] == []
|
|
|
|
|
|
assert raw["batches"][0]["lines"][0] == {
|
|
|
|
|
|
"product_id": "p_1", "qty": "10", "unit_cost": "20", "uom": "", "uom_size": "1"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
doc = Document.from_dict(raw)
|
|
|
|
|
|
assert doc.batches[0].lines[0].base_qty == Decimal("10.000")
|
|
|
|
|
|
assert doc.batches[0].lines[0].base_unit_cost == Decimal("20.00")
|
|
|
|
|
|
assert doc.sales[0].lines[0].base_unit_price == Decimal("30.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
def test_migration_keeps_derived_pack_cost():
|
|
|
|
|
|
"""У фасовки появилась своя закупочная цена — старые цифры не должны поехать.
|
|
|
|
|
|
|
|
|
|
|
|
Ноль означает «своей цены нет, считай от базовой», то есть ровно то, как
|
|
|
|
|
|
работала программа до появления поля.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = migrate(
|
|
|
|
|
|
{
|
|
|
|
|
|
"schema_version": 4,
|
|
|
|
|
|
"products": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": "p_1", "name": "Печенье", "unit": "шт",
|
|
|
|
|
|
"cost_price": "20", "retail_price": "30",
|
|
|
|
|
|
"packs": [{"name": "пачка", "size": "10", "retail_price": "200"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert raw["schema_version"] == SCHEMA_VERSION
|
|
|
|
|
|
assert raw["products"][0]["packs"][0]["cost_price"] == "0"
|
|
|
|
|
|
|
|
|
|
|
|
product = Document.from_dict(raw).products[0]
|
|
|
|
|
|
assert product.cost_for("пачка") == Decimal("200.00") # 20 × 10, как раньше
|
|
|
|
|
|
assert product.retail_for("пачка") == Decimal("200.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
def test_future_schema_refuses_to_open():
|
|
|
|
|
|
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
|
|
|
|
|
migrate({"schema_version": 999})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_corrupted_json_inside_valid_encryption(tmp_path):
|
|
|
|
|
|
"""GCM подтвердил целостность, но внутри не JSON — сообщаем внятно."""
|
|
|
|
|
|
path = tmp_path / "vault.fmdb"
|
|
|
|
|
|
path.write_bytes(crypto.encrypt(b"not json at all", "pw"))
|
|
|
|
|
|
with pytest.raises(crypto.VaultError, match="повреждено"):
|
|
|
|
|
|
Vault.open(path, "pw")
|