feat(bot): Коммит — создание, замена и удаление файлов, черновик
- новый файл: имя → содержимое текстом (```-обёртка снимается) или документом - новая папка (виртуальная, пока в ней нет файлов), замена с diff +N/−M, удаление - валидация: имя, ≤ 100 КБ, UTF-8, без NUL; .py — ast.parse с выбором «исправить / всё равно»; .sourcecraft/, .tgit/, ruff.toml из бота не меняются - DraftService: черновик в БД, 24 ч, ≤ 50 изменений; удаление нового файла убирает его из черновика - дерево показывает черновик поверх репозитория: 🆕 / ✏️ / 🗑, размер — как будет после пуша - экран черновика (A/M/D) с «↩️ убрать» Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import ChangeOp, User
|
||||
from app.db.session import SessionMaker
|
||||
from app.repo.drafts import MAX_CHANGES, DraftFull, DraftService
|
||||
|
||||
UID = 7
|
||||
SHA = "a" * 40
|
||||
|
||||
|
||||
class Clock:
|
||||
now = datetime(2026, 10, 15, tzinfo=UTC)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def svc(sessions: SessionMaker) -> DraftService:
|
||||
async with sessions.begin() as s:
|
||||
s.add(User(tg_id=UID, full_name="u"))
|
||||
return DraftService(sessions, clock=Clock())
|
||||
|
||||
|
||||
async def test_put_get_and_order(svc: DraftService) -> None:
|
||||
await svc.put(UID, SHA, "b.py", ChangeOp.ADD, b"b")
|
||||
await svc.put(UID, "other-sha", "a.py", ChangeOp.MODIFY, b"a")
|
||||
|
||||
draft = await svc.get(UID)
|
||||
assert draft is not None and draft.base_sha == SHA # base fixed when the draft started
|
||||
assert [(c.path, c.op) for c in draft.changes] == [("b.py", ChangeOp.ADD), ("a.py", ChangeOp.MODIFY)]
|
||||
|
||||
|
||||
async def test_editing_a_new_file_keeps_it_added_and_deleting_it_drops_it(svc: DraftService) -> None:
|
||||
await svc.put(UID, SHA, "n.py", ChangeOp.ADD, b"1")
|
||||
await svc.put(UID, SHA, "n.py", ChangeOp.MODIFY, b"2")
|
||||
draft = await svc.get(UID)
|
||||
assert draft is not None and draft.changes[0].op is ChangeOp.ADD and draft.changes[0].content == b"2"
|
||||
|
||||
await svc.put(UID, SHA, "n.py", ChangeOp.DELETE, None)
|
||||
draft = await svc.get(UID)
|
||||
assert draft is not None and draft.changes == []
|
||||
|
||||
|
||||
async def test_drop_message_clear(svc: DraftService) -> None:
|
||||
await svc.put(UID, SHA, "a.py", ChangeOp.ADD, b"1")
|
||||
await svc.put(UID, SHA, "b.py", ChangeOp.DELETE, None)
|
||||
await svc.drop(UID, "a.py")
|
||||
await svc.set_message(UID, "feat: x")
|
||||
|
||||
draft = await svc.get(UID)
|
||||
assert draft is not None and [c.path for c in draft.changes] == ["b.py"] and draft.message == "feat: x"
|
||||
await svc.clear(UID)
|
||||
assert await svc.get(UID) is None
|
||||
|
||||
|
||||
async def test_expired_draft_is_gone(svc: DraftService) -> None:
|
||||
await svc.put(UID, SHA, "a.py", ChangeOp.ADD, b"1")
|
||||
Clock.now += timedelta(hours=25)
|
||||
try:
|
||||
assert await svc.get(UID) is None
|
||||
finally:
|
||||
Clock.now -= timedelta(hours=25)
|
||||
|
||||
|
||||
async def test_draft_size_limit(svc: DraftService) -> None:
|
||||
for i in range(MAX_CHANGES):
|
||||
await svc.put(UID, SHA, f"f{i}.py", ChangeOp.ADD, b"")
|
||||
with pytest.raises(DraftFull):
|
||||
await svc.put(UID, SHA, "one-more.py", ChangeOp.ADD, b"")
|
||||
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
|
||||
from app.repo.validate import (
|
||||
ValidationError,
|
||||
check_content,
|
||||
check_name,
|
||||
diff_stat,
|
||||
is_protected,
|
||||
join,
|
||||
python_syntax,
|
||||
strip_fence,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["main.py", "a-b_c.1", ".gitignore", "X" * 100])
|
||||
def test_good_names(name: str) -> None:
|
||||
assert check_name(f" {name} ") == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", ".", "..", "a/b", "привет.py", "a b", "X" * 101, "a;rm"])
|
||||
def test_bad_names(name: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
check_name(name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "protected"),
|
||||
[
|
||||
(".sourcecraft/ci.yaml", True),
|
||||
(".tgit/x.py", True),
|
||||
("ruff.toml", True),
|
||||
(".sourcecraft", True),
|
||||
("services/a/ruff.toml", False),
|
||||
("README.md", False),
|
||||
("services/a/main.py", False),
|
||||
],
|
||||
)
|
||||
def test_protected_paths(path: str, protected: bool) -> None:
|
||||
assert is_protected(path) is protected
|
||||
|
||||
|
||||
def test_join() -> None:
|
||||
assert join("", "a.py") == "a.py" and join("s/x", "a.py") == "s/x/a.py"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("```python\nprint(1)\n```", "print(1)"),
|
||||
("```\na\nb\n```", "a\nb"),
|
||||
("print(1)", "print(1)"),
|
||||
("x ``` y", "x ``` y"),
|
||||
],
|
||||
)
|
||||
def test_strip_fence(text: str, expected: str) -> None:
|
||||
assert strip_fence(text) == expected
|
||||
|
||||
|
||||
def test_content_checks() -> None:
|
||||
assert check_content("привет".encode()) == "привет"
|
||||
for data, reason in [(b"a\0b", "binary"), (b"\xff\xfe", "encoding"), (b"x" * (100 * 1024 + 1), "too_big")]:
|
||||
with pytest.raises(ValidationError, match=reason):
|
||||
check_content(data)
|
||||
|
||||
|
||||
def test_python_syntax_only_for_py() -> None:
|
||||
issue = python_syntax("a.py", "def f(:\n pass\n")
|
||||
assert issue is not None and issue.line == 1
|
||||
assert python_syntax("a.py", "x = 1\n") is None
|
||||
assert python_syntax("a.txt", "def f(:") is None
|
||||
|
||||
|
||||
def test_diff_stat() -> None:
|
||||
assert diff_stat("a\nb\nc", "a\nB\nc\nd") == (2, 1)
|
||||
Reference in New Issue
Block a user