food-records/tests/test_gitsync.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

283 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты синхронизации против локального bare-репозитория — без сети."""
import subprocess
import pytest
from app import gitsync
from app.gitsync import Diverged, GitSync, auth_url, commit_message, scrub
from app.models import GitSettings
pytestmark = pytest.mark.skipif(
gitsync.git_executable() is None, reason="git не установлен"
)
REL = "data/vault.fmdb"
def git(cwd, *args):
proc = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
@pytest.fixture
def remote(tmp_path):
path = tmp_path / "remote.git"
path.mkdir()
git(path, "init", "--bare", "-b", "main")
return path
@pytest.fixture
def sync(tmp_path, remote):
work = tmp_path / "work"
(work / "data").mkdir(parents=True)
(work / "data" / "vault.fmdb").write_bytes(b"encrypted-v1")
settings = GitSettings(remote_url=remote.as_posix(), branch="main")
return GitSync(work, REL, settings)
def write_vault(sync, content: bytes):
(sync.repo_dir / REL).write_bytes(content)
def remote_log(remote):
return git(remote, "log", "--format=%H %s").splitlines()
def remote_vault(remote):
proc = subprocess.run(
["git", "show", f"main:{REL}"], cwd=remote, capture_output=True
)
assert proc.returncode == 0, proc.stderr
return proc.stdout
# --- базовый цикл ---------------------------------------------------------
def test_first_sync_creates_repo_and_pushes(sync, remote):
result = sync.sync()
assert result.committed and result.pushed
assert remote_vault(remote) == b"encrypted-v1"
assert len(remote_log(remote)) == 1
def test_sync_without_changes_does_nothing(sync, remote):
sync.sync()
result = sync.sync()
assert result.committed is False
assert result.pushed is False
assert len(remote_log(remote)) == 1
def test_commit_touches_only_the_vault(sync, remote):
"""Незакоммиченные правки исходников не должны уезжать вместе с данными."""
(sync.repo_dir / "app.py").write_text("print('работа в процессе')", encoding="utf-8")
(sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8")
sync.sync()
files = git(remote, "show", "--name-only", "--format=", "main").split()
assert files == [REL]
def test_commit_message_leaks_no_business_numbers(sync, remote):
sync.sync()
subject = remote_log(remote)[0].split(" ", 1)[1]
assert subject == commit_message()
assert subject.startswith("vault ")
# --- схлопывание коммитов по дням -----------------------------------------
def test_same_day_syncs_collapse_into_one_commit(sync, remote):
"""Ради этого всё и затевалось: не 24 копии файла в сутки, а одна."""
sync.sync()
for i in range(2, 5):
write_vault(sync, f"encrypted-v{i}".encode())
result = sync.sync()
assert result.amended is True
assert len(remote_log(remote)) == 1
assert remote_vault(remote) == b"encrypted-v4"
def test_squash_can_be_turned_off(sync, remote):
sync.settings.daily_squash = False
sync.sync()
write_vault(sync, b"encrypted-v2")
result = sync.sync()
assert result.amended is False
assert len(remote_log(remote)) == 2
def test_no_amend_when_head_is_not_on_the_server(sync):
"""Переписывать можно только ту вершину, которая точно наша и уже запушена."""
sync.ensure_repo()
sync._run(["add", "--", REL])
sync._run(["commit", "-m", commit_message(), "--", REL])
assert sync._can_amend(remote=None) is False
assert sync._can_amend(remote="0" * 40) is False
def test_no_amend_over_a_foreign_commit(sync):
"""Чужой коммит не наш формат — трогать его нельзя."""
sync.ensure_repo()
(sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8")
sync._run(["add", "-A"])
sync._run(["commit", "-m", "правки руками"])
assert sync._can_amend(remote=sync.head_sha()) is False
def test_no_amend_over_a_commit_touching_other_files(sync):
"""Amend утащил бы чужие изменения из той же вершины."""
sync.ensure_repo()
(sync.repo_dir / "readme.md").write_text("привет", encoding="utf-8")
sync._run(["add", "-A"])
sync._run(["commit", "-m", commit_message()])
assert sync._can_amend(remote=sync.head_sha()) is False
def test_yesterdays_commit_is_not_amended(sync, remote):
sync.sync()
sync._run(["commit", "--amend", "-m", "vault 2020-01-01"])
assert sync._can_amend(remote=sync.head_sha()) is False
# --- защита от затирания чужой работы -------------------------------------
def test_stale_lease_is_refused_and_remote_survives(sync, remote, tmp_path):
"""Ключевая проверка безопасности схлопывания.
Между нашим fetch и push другая машина успела запушить своё. Мы уже
переписали локальную вершину — force-with-lease обязан это поймать.
"""
sync.sync()
stale = sync.head_sha()
other = tmp_path / "other"
git(tmp_path, "clone", remote.as_posix(), "other")
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
git(other, "push", "origin", "main")
theirs = git(other, "rev-parse", "HEAD")
write_vault(sync, b"encrypted-ours")
committed, amended, lease = sync._commit(remote=stale)
assert (committed, amended, lease) == (True, True, stale)
with pytest.raises(Diverged):
sync._push(amended=True, lease=lease)
# Чужой коммит на месте, наш форс его не снёс.
assert git(remote, "rev-parse", "main") == theirs
assert remote_vault(remote) == b"encrypted-from-other-machine"
def test_remote_ahead_and_clean_fast_forwards(sync, remote, tmp_path):
sync.sync()
other = tmp_path / "other"
git(tmp_path, "clone", remote.as_posix(), "other")
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
git(other, "push", "origin", "main")
result = sync.sync()
assert result.pulled is True
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine"
def test_edits_on_both_machines_raise_and_keep_both_copies(sync, remote, tmp_path):
"""Ничего не сливаем и ничего не теряем — решение за пользователем."""
sync.sync()
other = tmp_path / "other"
git(tmp_path, "clone", remote.as_posix(), "other")
(other / "data" / "vault.fmdb").write_bytes(b"encrypted-from-other-machine")
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
git(other, "push", "origin", "main")
write_vault(sync, b"encrypted-ours")
copy = sync.repo_dir / "data" / "vault.remote.fmdb"
with pytest.raises(Diverged):
sync.sync(remote_copy_path=copy)
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-ours"
assert copy.read_bytes() == b"encrypted-from-other-machine"
# --- обращение с токеном --------------------------------------------------
def test_auth_url_injects_token():
url = auth_url("https://gt.ser.gay/kizya/food-market.git", "glpat-secret")
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-market.git"
def test_auth_url_replaces_existing_credentials():
url = auth_url("https://old:creds@gt.ser.gay/kizya/food-market.git", "glpat-new")
assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-market.git"
def test_auth_url_escapes_special_characters():
url = auth_url("https://gt.ser.gay/x.git", "a/b@c:d")
assert "a%2Fb%40c%3Ad" in url
assert url.count("@") == 1
def test_auth_url_left_alone_for_local_paths():
assert auth_url("/srv/repo.git", "glpat-secret") == "/srv/repo.git"
def test_token_is_not_written_into_git_config(sync):
sync.settings.token = "glpat-secret"
sync.ensure_repo()
config = (sync.repo_dir / ".git" / "config").read_text(encoding="utf-8")
assert "glpat-secret" not in config
def test_scrub_hides_token_in_messages():
assert scrub("fatal: https://oauth2:glpat-x@host", "glpat-x") == "fatal: https://oauth2:***@host"
def test_errors_do_not_leak_the_token(sync):
sync.settings.remote_url = "https://127.0.0.1:1/nope.git"
sync.settings.token = "glpat-supersecret"
with pytest.raises(gitsync.GitError) as exc:
sync.sync()
assert "glpat-supersecret" not in str(exc.value)
# --- прочее ---------------------------------------------------------------
def test_sync_without_remote_is_a_clear_error(sync):
sync.settings.remote_url = ""
with pytest.raises(gitsync.GitError, match="адрес репозитория"):
sync.sync()
def test_ensure_repo_is_idempotent(sync):
sync.ensure_repo()
sync.ensure_repo()
assert sync.is_repo()
assert git(sync.repo_dir, "remote", "get-url", "origin")
def test_ensure_repo_updates_a_changed_remote(sync, tmp_path):
sync.ensure_repo()
sync.settings.remote_url = (tmp_path / "elsewhere.git").as_posix()
sync.ensure_repo()
assert git(sync.repo_dir, "remote", "get-url", "origin").endswith("elsewhere.git")