feat(bot): Коммит — навигация по дереву репозитория загрузок
- UploadsRepo: локальный клон, fetch не чаще fetch_interval, ls-tree -z (пробелы/кириллица), cat-file, последний коммит по пути; git без shell, ssh с -F /dev/null (без конфига хоста) - «📝 Коммит»: папки/файлы с размерами, ⬆️ Вверх, страницы по 20, карточка файла с последним коммитом, «👁 Показать» (<pre> или документом); в кнопках только индексы - ключ для контейнера: GIT_SSH_KEY_B64 → временный файл 0600 (смонтированный файл хоста контейнерному uid не прочитать); volume /data для клона - тесты на настоящем bare-репозитории во временной папке Refs #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendDocument, SendMessage
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from app.access.service import AccessService
|
||||
from app.bot.routers.commit import Act, CommitCb, human_size
|
||||
from app.db.session import SessionMaker
|
||||
from app.main import build_dispatcher
|
||||
from app.repo.git import UploadsRepo
|
||||
from app.texts import Texts
|
||||
from tests.conftest import ADMIN
|
||||
from tests.fakes import TgClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
|
||||
return TgClient(build_dispatcher(texts, sessions, access, repo))
|
||||
|
||||
|
||||
def labels(markup: object) -> list[str]:
|
||||
assert isinstance(markup, InlineKeyboardMarkup)
|
||||
return [b.text for row in markup.inline_keyboard for b in row]
|
||||
|
||||
|
||||
def cb(action: Act, i: int = 0) -> str:
|
||||
return CommitCb(a=action, i=i).pack()
|
||||
|
||||
|
||||
async def test_root_listing(tg: TgClient, texts: Texts) -> None:
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
|
||||
[reply] = tg.session.sent(SendMessage)
|
||||
assert reply.text == texts.commit.title.format(path="")
|
||||
got = labels(reply.reply_markup)
|
||||
assert got[:4] == ["📁 docs", "📁 many", "📁 services", "📄 README.md · 10 Б"]
|
||||
assert texts.commit.up not in got # already at the root
|
||||
|
||||
|
||||
async def test_open_dir_then_file_card_then_back(tg: TgClient, texts: Texts) -> None:
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 2)) # services
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 0)) # services/hello
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 0)) # services/hello/main.py
|
||||
|
||||
hello_dir, hello_sub, card = tg.session.sent(EditMessageText)
|
||||
assert hello_dir.text == texts.commit.title.format(path="services")
|
||||
assert texts.commit.up in labels(hello_dir.reply_markup)
|
||||
assert hello_sub.text == texts.commit.title.format(path="services/hello")
|
||||
assert card.text.startswith("📄 /services/hello/main.py") and "init" in card.text
|
||||
assert labels(card.reply_markup) == [
|
||||
texts.commit.view,
|
||||
texts.commit.replace,
|
||||
texts.commit.delete,
|
||||
texts.commit.to_dir,
|
||||
]
|
||||
|
||||
await tg.press(ADMIN, cb(Act.TO_DIR))
|
||||
await tg.press(ADMIN, cb(Act.UP))
|
||||
*_, back_to_dir, up = tg.session.sent(EditMessageText)
|
||||
assert back_to_dir.text == texts.commit.title.format(path="services/hello")
|
||||
assert up.text == texts.commit.title.format(path="services")
|
||||
|
||||
|
||||
async def test_view_text_inline_and_binary_as_document(tg: TgClient, texts: Texts) -> None:
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 0)) # docs
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 1)) # мои заметки.md
|
||||
await tg.press(ADMIN, cb(Act.VIEW))
|
||||
await tg.press(ADMIN, cb(Act.TO_DIR))
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 0)) # logo.bin
|
||||
await tg.press(ADMIN, cb(Act.VIEW))
|
||||
|
||||
inline = [m for m in tg.session.sent(SendMessage) if m.parse_mode == "HTML"]
|
||||
assert len(inline) == 1 and "<pre>привет\n</pre>" in inline[0].text
|
||||
[doc] = tg.session.sent(SendDocument)
|
||||
assert doc.caption == texts.commit.sent_as_file
|
||||
|
||||
|
||||
async def test_pagination(tg: TgClient, texts: Texts) -> None:
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 1)) # many: 25 files
|
||||
await tg.press(ADMIN, cb(Act.PAGE, 1))
|
||||
|
||||
first, second = tg.session.sent(EditMessageText)
|
||||
assert texts.commit.page.format(page=1, pages=2) in first.text
|
||||
assert labels(first.reply_markup)[0].startswith("📄 f00.txt") and texts.commit.next in labels(first.reply_markup)
|
||||
assert labels(second.reply_markup)[0].startswith("📄 f20.txt") and texts.commit.prev in labels(second.reply_markup)
|
||||
|
||||
|
||||
async def test_unfinished_actions_and_stale_buttons(tg: TgClient, texts: Texts) -> None:
|
||||
await tg.send(ADMIN, texts.menu.commit)
|
||||
await tg.press(ADMIN, cb(Act.NEW_FILE))
|
||||
await tg.press(ADMIN, cb(Act.OPEN, 99))
|
||||
|
||||
soon, stale = tg.session.sent(AnswerCallbackQuery)
|
||||
assert soon.text == texts.commit.soon and soon.show_alert
|
||||
assert stale.text is None
|
||||
assert tg.session.sent(EditMessageText) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("n", "text"), [(10, "10 Б"), (2048, "2.0 КБ"), (5 * 1024 * 1024, "5.0 МБ")])
|
||||
def test_human_size(n: int, text: str) -> None:
|
||||
assert human_size(n) == text
|
||||
@@ -6,14 +6,15 @@ from app.access.service import AccessService
|
||||
from app.bot.menu import Nav, NavAction
|
||||
from app.db.session import SessionMaker
|
||||
from app.main import build_dispatcher
|
||||
from app.repo.git import UploadsRepo
|
||||
from app.texts import Texts
|
||||
from tests.conftest import ADMIN, OUTSIDER
|
||||
from tests.fakes import TgClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
|
||||
return TgClient(build_dispatcher(texts, sessions, access))
|
||||
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
|
||||
return TgClient(build_dispatcher(texts, sessions, access, repo))
|
||||
|
||||
|
||||
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
||||
@@ -45,7 +46,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
|
||||
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts"])
|
||||
@pytest.mark.parametrize("section", ["status", "logs", "alerts"])
|
||||
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
|
||||
await tg.send(ADMIN, getattr(texts.menu, section))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.access.service import AccessService
|
||||
from app.bot.routers.members import MembersAction, MembersCb
|
||||
from app.db.session import SessionMaker
|
||||
from app.main import build_dispatcher
|
||||
from app.repo.git import UploadsRepo
|
||||
from app.texts import Texts
|
||||
from tests.conftest import ADMIN
|
||||
from tests.fakes import TgClient
|
||||
@@ -16,8 +17,8 @@ NEWBIE = 3003
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
|
||||
return TgClient(build_dispatcher(texts, sessions, access))
|
||||
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
|
||||
return TgClient(build_dispatcher(texts, sessions, access, repo))
|
||||
|
||||
|
||||
async def invite_token(tg: TgClient) -> str:
|
||||
|
||||
+44
-1
@@ -1,3 +1,4 @@
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
@@ -5,9 +6,10 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.access.service import AccessService
|
||||
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
|
||||
from app.config import AccessSettings, GitSettings, RuntimeSettings, Settings, TelegramSettings
|
||||
from app.db.migrate import upgrade
|
||||
from app.db.session import SessionMaker, make_engine, make_sessionmaker
|
||||
from app.repo.git import UploadsRepo
|
||||
from app.texts import Texts, load_texts
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -48,3 +50,44 @@ async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
|
||||
service = AccessService(sessions, settings.access)
|
||||
await service.bootstrap_admins()
|
||||
return service
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> str:
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "Тест",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "Тест",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"HOME": str(cwd),
|
||||
}
|
||||
return subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote(tmp_path: Path) -> Path:
|
||||
"""A bare 'SourceCraft' repo: services/hello, a file with a space and Cyrillic, a binary, 25 files in one dir."""
|
||||
work = tmp_path / "seed"
|
||||
work.mkdir()
|
||||
git(work, "init", "-q", "-b", "main")
|
||||
(work / "services" / "hello").mkdir(parents=True)
|
||||
(work / "services" / "hello" / "main.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
(work / "README.md").write_text("# uploads\n", encoding="utf-8")
|
||||
git(work, "add", "-A")
|
||||
git(work, "commit", "-q", "-m", "init")
|
||||
(work / "docs").mkdir()
|
||||
(work / "docs" / "мои заметки.md").write_text("привет\n", encoding="utf-8")
|
||||
(work / "docs" / "logo.bin").write_bytes(b"\x00\x01\x02binary")
|
||||
(work / "many").mkdir()
|
||||
for i in range(25):
|
||||
(work / "many" / f"f{i:02}.txt").write_text(str(i), encoding="utf-8")
|
||||
git(work, "add", "-A")
|
||||
git(work, "commit", "-q", "-m", "docs and many")
|
||||
bare = tmp_path / "remote.git"
|
||||
git(tmp_path, "clone", "-q", "--bare", str(work), str(bare))
|
||||
return bare
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(remote: Path, tmp_path: Path) -> UploadsRepo:
|
||||
return UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=tmp_path / "clone", fetch_interval=0))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repo.git import GitError, UploadsRepo
|
||||
from tests.conftest import git
|
||||
|
||||
|
||||
async def test_clone_and_list_root_dirs_first(repo: UploadsRepo) -> None:
|
||||
await repo.refresh()
|
||||
|
||||
entries = await repo.list_dir()
|
||||
assert [(e.name, e.kind) for e in entries] == [
|
||||
("docs", "dir"),
|
||||
("many", "dir"),
|
||||
("services", "dir"),
|
||||
("README.md", "file"),
|
||||
]
|
||||
assert entries[-1].size == len("# uploads\n")
|
||||
|
||||
|
||||
async def test_unicode_and_spaces_in_names(repo: UploadsRepo) -> None:
|
||||
await repo.refresh()
|
||||
|
||||
names = [e.path for e in await repo.list_dir("docs")]
|
||||
assert names == ["docs/logo.bin", "docs/мои заметки.md"]
|
||||
assert (await repo.read("docs/мои заметки.md")).decode() == "привет\n"
|
||||
|
||||
|
||||
async def test_last_commit_per_path(repo: UploadsRepo) -> None:
|
||||
await repo.refresh()
|
||||
|
||||
hello = await repo.last_commit("services/hello/main.py")
|
||||
docs = await repo.last_commit("docs/logo.bin")
|
||||
assert hello is not None and hello.subject == "init" and hello.author == "Тест"
|
||||
assert docs is not None and docs.subject == "docs and many" and len(docs.sha) == 40
|
||||
|
||||
|
||||
async def test_missing_dir_is_an_error(repo: UploadsRepo) -> None:
|
||||
await repo.refresh()
|
||||
with pytest.raises(GitError, match="no such directory"):
|
||||
await repo.list_dir("nope")
|
||||
|
||||
|
||||
async def test_refresh_sees_new_commits_and_respects_interval(repo: UploadsRepo, remote: Path, tmp_path: Path) -> None:
|
||||
await repo.refresh()
|
||||
before = await repo.head()
|
||||
|
||||
other = tmp_path / "other"
|
||||
git(tmp_path, "clone", "-q", str(remote), str(other))
|
||||
(other / "new.txt").write_text("x", encoding="utf-8")
|
||||
git(other, "add", "-A")
|
||||
git(other, "commit", "-q", "-m", "new")
|
||||
git(other, "push", "-q", "origin", "main")
|
||||
|
||||
await repo.refresh()
|
||||
assert await repo.head() != before
|
||||
assert "new.txt" in [e.name for e in await repo.list_dir()]
|
||||
|
||||
|
||||
async def test_fetch_is_throttled(remote: Path, tmp_path: Path) -> None:
|
||||
from app.config import GitSettings
|
||||
|
||||
throttled = UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=tmp_path / "c2", fetch_interval=3600))
|
||||
await throttled.refresh()
|
||||
head = await throttled.head()
|
||||
other = tmp_path / "o2"
|
||||
git(tmp_path, "clone", "-q", str(remote), str(other))
|
||||
(other / "x.txt").write_text("x", encoding="utf-8")
|
||||
git(other, "add", "-A")
|
||||
git(other, "commit", "-q", "-m", "x")
|
||||
git(other, "push", "-q", "origin", "main")
|
||||
|
||||
await throttled.refresh()
|
||||
assert await throttled.head() == head # within the interval: no fetch
|
||||
await throttled.refresh(force=True)
|
||||
assert await throttled.head() != head
|
||||
|
||||
|
||||
async def test_unreachable_remote(tmp_path: Path) -> None:
|
||||
from app.config import GitSettings
|
||||
|
||||
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
|
||||
with pytest.raises(GitError, match="clone"):
|
||||
await broken.refresh()
|
||||
Reference in New Issue
Block a user