food-market/tests/test_gitsync.py
Claude efce552e2b Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.

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-22 21:25:39 +03:00

374 lines
13 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 = "vault.fmdb"
SETUP = {".gitattributes", ".gitignore"}
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 / "data"
work.mkdir(parents=True)
(work / REL).write_bytes(b"encrypted-v1")
settings = GitSettings(remote_url=remote.as_posix(), branch="main")
return GitSync(work, REL, settings)
@pytest.fixture
def settled(sync, remote):
"""Репозиторий после двух синхронизаций.
Первый коммит несёт служебные файлы, поэтому переписывать его нельзя.
Дальнейшие коммиты — чистая база, и вот они уже схлопываются.
"""
sync.sync()
write_vault(sync, b"encrypted-v2")
sync.sync()
return sync
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_files(remote):
return set(git(remote, "ls-tree", "-r", "--name-only", "main").split())
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(settled, remote):
result = settled.sync()
assert result.committed is False
assert result.pushed is False
assert len(remote_log(remote)) == 2
def test_data_repo_holds_nothing_but_the_vault(sync, remote):
"""Ради этого база и вынесена в свой репозиторий.
Что бы ни оказалось в папке рядом — в репозиторий данных уезжает только
сама база и пара служебных файлов.
"""
(sync.repo_dir / "main.py").write_text("код программы", encoding="utf-8")
(sync.repo_dir / "notes.txt").write_text("черновик", encoding="utf-8")
(sync.repo_dir / "app").mkdir()
(sync.repo_dir / "app" / "ledger.py").write_text("код", encoding="utf-8")
sync.sync()
assert remote_files(remote) == {REL} | SETUP
def test_setup_files_protect_the_binary(sync, remote):
"""Без пометки binary git подставил бы CRLF и база перестала бы читаться."""
sync.sync()
attributes = git(remote, "show", "main:.gitattributes")
assert "vault.fmdb binary" in attributes
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 seed_remote(tmp_path, remote, files: dict[str, bytes]) -> str:
"""Положить в удалённый репозиторий начальную историю."""
seed = tmp_path / "seed"
seed.mkdir()
git(seed, "init", "-b", "main")
for name, content in files.items():
(seed / name).write_bytes(content)
git(seed, "add", "-A")
git(seed, "-c", "user.email=s@s", "-c", "user.name=s", "commit", "-m", "начало")
git(seed, "push", remote.as_posix(), "main")
return git(seed, "rev-parse", "HEAD")
def test_first_sync_builds_on_existing_remote_history(sync, remote, tmp_path):
"""Репозиторий заводят с README — своя параллельная история не годится."""
seeded = seed_remote(tmp_path, remote, {"README.md": b"# food-records\n"})
result = sync.sync()
assert result.pushed is True
assert remote_vault(remote) == b"encrypted-v1"
assert "README.md" in remote_files(remote)
# История линейная: наш коммит лёг поверх, а не рядом.
log = remote_log(remote)
assert len(log) == 2
assert log[-1].startswith(seeded)
def test_existing_remote_vault_is_never_silently_replaced(sync, remote, tmp_path):
"""На сервере уже есть база, локально завели новую — обе настоящие."""
seed_remote(tmp_path, remote, {REL: b"encrypted-on-server"})
copy = sync.repo_dir / "vault.remote.fmdb"
with pytest.raises(Diverged):
sync.sync(remote_copy_path=copy)
assert (sync.repo_dir / REL).read_bytes() == b"encrypted-v1"
assert copy.read_bytes() == b"encrypted-on-server"
assert remote_vault(remote) == b"encrypted-on-server"
# --- схлопывание коммитов по дням -----------------------------------------
def test_same_day_syncs_collapse_into_one_commit(settled, remote):
"""Ради этого всё и затевалось: не 24 копии файла в сутки, а одна."""
before = len(remote_log(remote))
for i in range(3, 6):
write_vault(settled, f"encrypted-v{i}".encode())
assert settled.sync().amended is True
assert len(remote_log(remote)) == before
assert remote_vault(remote) == b"encrypted-v5"
def test_the_setup_commit_is_never_rewritten(sync, remote):
"""Первая вершина несёт не только базу, поэтому amend по ней запрещён."""
sync.sync()
write_vault(sync, b"encrypted-v2")
result = sync.sync()
assert result.amended is False
assert len(remote_log(remote)) == 2
def test_squash_can_be_turned_off(settled, remote):
settled.settings.daily_squash = False
before = len(remote_log(remote))
write_vault(settled, b"encrypted-v9")
assert settled.sync().amended is False
assert len(remote_log(remote)) == before + 1
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(settled):
settled._run(["commit", "--amend", "-m", "vault 2020-01-01"])
assert settled._can_amend(remote=settled.head_sha()) is False
# --- защита от затирания чужой работы -------------------------------------
def clone_and_push(tmp_path, remote, content: bytes) -> str:
"""Смоделировать вторую машину, которая запушила своё."""
other = tmp_path / "other"
git(tmp_path, "clone", remote.as_posix(), "other")
(other / REL).write_bytes(content)
git(other, "-c", "user.email=o@o", "-c", "user.name=o", "commit", "-am", "vault 2030-01-01")
git(other, "push", "origin", "main")
return git(other, "rev-parse", "HEAD")
def test_stale_lease_is_refused_and_remote_survives(settled, remote, tmp_path):
"""Ключевая проверка безопасности схлопывания.
Между нашим fetch и push другая машина успела запушить своё. Мы уже
переписали локальную вершину — force-with-lease обязан это поймать.
"""
stale = settled.head_sha()
theirs = clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
committed, amended, lease = settled._commit(remote=stale)
assert (committed, amended, lease) == (True, True, stale)
with pytest.raises(Diverged):
settled._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(settled, remote, tmp_path):
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
result = settled.sync()
assert result.pulled is True
assert (settled.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine"
def test_edits_on_both_machines_raise_and_keep_both_copies(settled, remote, tmp_path):
"""Ничего не сливаем и ничего не теряем — решение за пользователем."""
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
copy = settled.repo_dir / "vault.remote.fmdb"
with pytest.raises(Diverged):
settled.sync(remote_copy_path=copy)
assert (settled.repo_dir / REL).read_bytes() == b"encrypted-ours"
assert copy.read_bytes() == b"encrypted-from-other-machine"
def test_force_push_overwrites_only_when_asked(settled, remote, tmp_path):
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
settled.force_push()
assert remote_vault(remote) == b"encrypted-ours"
def test_reset_to_remote_drops_local_changes(settled, remote, tmp_path):
clone_and_push(tmp_path, remote, b"encrypted-from-other-machine")
write_vault(settled, b"encrypted-ours")
settled.reset_to_remote()
assert (settled.repo_dir / REL).read_bytes() == b"encrypted-from-other-machine"
# --- обращение с токеном --------------------------------------------------
def test_auth_url_injects_token():
url = auth_url("https://gt.ser.gay/kizya/food-records.git", "glpat-secret")
assert url == "https://oauth2:glpat-secret@gt.ser.gay/kizya/food-records.git"
def test_auth_url_replaces_existing_credentials():
url = auth_url("https://old:creds@gt.ser.gay/kizya/food-records.git", "glpat-new")
assert url == "https://oauth2:glpat-new@gt.ser.gay/kizya/food-records.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")
def test_default_remote_points_at_the_data_repo():
"""Настройки по умолчанию должны вести в репозиторий данных, а не в код."""
assert "food-records" in GitSettings().remote_url