Три правки по замечаниям. 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>
374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""Тесты синхронизации против локального 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
|