from datetime import timedelta from pathlib import Path import pytest from sqlalchemy import select from app.access.service import AccessService from app.db.models import ChangeOp, PipelineStatus, Push, User, utcnow from app.db.session import SessionMaker from app.repo.drafts import Change, DraftView from app.repo.git import UploadsRepo from app.repo.publisher import PUSHES_PER_HOUR, Publisher, PushStatus from tests.conftest import ADMIN, git @pytest.fixture async def member(access: AccessService) -> User: user = await access.get_member(ADMIN) assert user is not None return user def remote_log(remote: Path, tmp_path: Path, name: str = "check") -> tuple[Path, str]: clone = tmp_path / name git(tmp_path, "clone", "-q", str(remote), str(clone)) return clone, git(clone, "log", "-1", "--format=%an <%ae>%n%B") def foreign_commit(remote: Path, tmp_path: Path, path: str, text: str, name: str) -> None: other = tmp_path / name git(tmp_path, "clone", "-q", str(remote), str(other)) target = other / path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8") git(other, "add", "-A") git(other, "commit", "-q", "-m", f"foreign {path}") git(other, "push", "-q", "origin", "main") async def draft_on(repo: UploadsRepo, *changes: Change) -> DraftView: await repo.refresh(force=True) return DraftView(base_sha=await repo.head(), message=None, changes=list(changes)) async def test_push_applies_changes_with_trailer( repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path ) -> None: draft = await draft_on( repo, Change("services/hello/utils.py", ChangeOp.ADD, b"X = 1\n"), Change("services/hello/main.py", ChangeOp.MODIFY, b"print('v2')\n"), Change("docs/logo.bin", ChangeOp.DELETE, None), Change("docs/мои заметки.md", ChangeOp.DELETE, None), ) publisher = Publisher(repo, sessions) result = await publisher.push(member, draft, "feat: v2\n\nподробности") assert result.status is PushStatus.OK and result.sha is not None clone, log = remote_log(remote, tmp_path) assert log.startswith("tgit-bot \nfeat: v2\n\nподробности\n\nTelegram-User: ") assert f"({ADMIN})" in log assert (clone / "services/hello/utils.py").read_text() == "X = 1\n" assert (clone / "services/hello/main.py").read_text() == "print('v2')\n" assert not (clone / "docs").exists() # both files gone → the folder is gone too assert git(clone, "rev-parse", "HEAD").strip() == result.sha async def test_nothing_to_commit(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None: draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"# uploads\n")) assert (await Publisher(repo, sessions).push(member, draft, "noop")).status is PushStatus.EMPTY async def test_conflict_then_overwrite( repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path ) -> None: draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"mine\n")) foreign_commit(remote, tmp_path, "README.md", "theirs\n", "other") publisher = Publisher(repo, sessions) conflict = await publisher.push(member, draft, "edit readme") assert conflict.status is PushStatus.CONFLICT and conflict.conflicts == ["README.md"] forced = await publisher.push(member, draft, "edit readme", overwrite=True) assert forced.status is PushStatus.OK clone, _ = remote_log(remote, tmp_path) assert (clone / "README.md").read_text() == "mine\n" async def test_unrelated_change_in_main_is_not_a_conflict( repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path ) -> None: draft = await draft_on(repo, Change("a.txt", ChangeOp.ADD, b"a")) foreign_commit(remote, tmp_path, "b.txt", "b", "other") assert (await Publisher(repo, sessions).push(member, draft, "add a")).status is PushStatus.OK clone, _ = remote_log(remote, tmp_path) assert (clone / "a.txt").exists() and (clone / "b.txt").exists() async def test_rejected_push_is_rebased( repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path ) -> None: raced = False async def someone_pushes_first() -> None: nonlocal raced if not raced: raced = True foreign_commit(remote, tmp_path, "race.txt", "r", "racer") draft = await draft_on(repo, Change("mine.txt", ChangeOp.ADD, b"m")) result = await Publisher(repo, sessions, before_push=someone_pushes_first).push(member, draft, "add mine") assert result.status is PushStatus.OK and raced clone, log = remote_log(remote, tmp_path) assert "add mine" in log and (clone / "race.txt").exists() and (clone / "mine.txt").exists() async def test_path_escape_is_refused(repo: UploadsRepo, sessions: SessionMaker, member: User, tmp_path: Path) -> None: draft = await draft_on(repo, Change("../evil.txt", ChangeOp.ADD, b"x")) assert (await Publisher(repo, sessions).push(member, draft, "evil")).status is PushStatus.FAILED assert not (tmp_path / "evil.txt").exists() async def test_rate_limit_and_record(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None: publisher = Publisher(repo, sessions) draft = await draft_on(repo, Change("x.txt", ChangeOp.ADD, b"x")) result = await publisher.push(member, draft, "x") status = (PipelineStatus.PENDING, PipelineStatus.SKIPPED) await publisher.record(member, result, "x", draft, chat_id=ADMIN, message_id=77, status=status) async with sessions() as s: push = await s.scalar(select(Push)) assert push is not None and push.sha == result.sha and push.paths == ["x.txt"] and push.message_id == 77 assert (push.ci_status, push.cd_status) == status for i in range(PUSHES_PER_HOUR): s.add( Push(user_id=ADMIN, sha=f"{i:040d}", message="m", chat_id=1, created_at=utcnow() - timedelta(minutes=1)) ) await s.commit() assert (await publisher.push(member, draft, "y")).status is PushStatus.LIMIT