wrolpi/modules/repos/test/test_api.py
Roland b3255e715c
Add a Repos module that mirrors git repositories (backend)
A Repo is a Collection (kind='repo') plus a `repository` row, kept up to
date by a recurring Download handled by the new `git` downloader.

* Full history by default (all branches and tags, no --prune; a force
  push saves the old HEAD under refs/wrolpi/backup/); snapshot mode
  (--depth 1) and a pinned branch are optional.  `/tree/<branch>` URLs
  select a branch.
* git runs HTTPS-only, never prompts, never runs hooks, and checks out
  symlinks as plain files.  Upstream disappearing defers the download
  with git's error; the local copy is kept.
* Repos are cloned to `repos_destination` (repos/%(repo_tag)s/%(repo_name)s)
  and the `repos` directory is always ignored, so repo files are never
  indexed.
* repos.yaml is the source of truth (import, dump, backup restore).
* /api/repos: list, create, get, edit, delete (files kept unless
  delete_files), update now, and a per-directory file tree.  Tagging uses
  the collections tag endpoint.

Also:
* run_command/process_runner can merge stderr into stdout (git writes
  progress to stderr).
* move_collection deletes an old directory that only holds empty
  directories (the move left an emptied .git behind).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00

137 lines
6.6 KiB
Python

import json
from http import HTTPStatus
import pytest
from modules.repos import lib
from modules.repos.models import Repository
from wrolpi.downloader import Download, DownloadFrequency, DownloadStatus
@pytest.mark.asyncio
async def test_repos_crud(test_session, test_directory, async_client, test_wrolpi_config):
body = dict(url='https://github.com/kiwix/kiwix-tools', tag_name='software', frequency=DownloadFrequency.daily)
request, response = await async_client.post('/api/repos', content=json.dumps(body))
assert response.status_code == HTTPStatus.CREATED, response.json
repo = response.json['repo']
assert repo['name'] == 'kiwix-tools'
assert repo['tag_name'] == 'software'
assert repo['directory'] == 'repos/software/kiwix-tools'
assert repo['mode'] == 'full'
assert repo['frequency'] == DownloadFrequency.daily
assert repo['download_status'] == DownloadStatus.new
assert repo['location'] == f'/repos/{repo["id"]}'
repo_id = repo['id']
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='https://github.com/a/b')))
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.get('/api/repos')
assert response.status_code == HTTPStatus.OK
assert [i['name'] for i in response.json['repos']] == ['b', 'kiwix-tools']
body = dict(description='Kiwix tools', mode='snapshot', branch='dev', frequency=DownloadFrequency.weekly)
request, response = await async_client.put(f'/api/repos/{repo_id}', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
request, response = await async_client.get(f'/api/repos/{repo_id}')
repo = response.json['repo']
assert (repo['description'], repo['mode'], repo['branch'], repo['frequency']) == \
('Kiwix tools', 'snapshot', 'dev', DownloadFrequency.weekly)
request, response = await async_client.delete(f'/api/repos/{repo_id}')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get(f'/api/repos/{repo_id}')
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json['code'] == 'UNKNOWN_REPO'
@pytest.mark.asyncio
async def test_create_repo_errors(test_session, async_client, test_wrolpi_config):
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='git@github.com:a/b')))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json['code'] == 'INVALID_REPO'
request, response = await async_client.post('/api/repos', content=json.dumps(dict(url='https://github.com/a/b')))
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.post('/api/repos',
content=json.dumps(dict(url='https://github.com/a/b.git')))
assert response.status_code == HTTPStatus.CONFLICT
assert response.json['code'] == 'REPO_CONFLICT'
@pytest.mark.asyncio
async def test_repo_tree_and_update(test_session, async_client, git_remote_factory, repos_download_manager):
remote = git_remote_factory(files={'README.md': '# Example', 'docs/guide.md': 'guide'})
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
request, response = await async_client.get(f'/api/repos/{repo.id}/tree')
assert response.status_code == HTTPStatus.OK
assert response.json['readme_path'] == 'README.md'
assert [i['path'] for i in response.json['entries']] == ['docs', 'README.md']
request, response = await async_client.get(f'/api/repos/{repo.id}/tree?path=docs')
assert [i['path'] for i in response.json['entries']] == ['docs/guide.md']
request, response = await async_client.get(f'/api/repos/{repo.id}/tree?path=../..')
assert response.status_code == HTTPStatus.BAD_REQUEST
# "Update now" renews the download.
new_head = remote.commit({'new.txt': 'new'}, 'New')
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
assert response.status_code == HTTPStatus.NO_CONTENT
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert test_session.query(Repository).one().head_sha == new_head
@pytest.mark.asyncio
async def test_tag_repo_moves_clone(test_session, test_directory, async_client, git_remote_factory,
repos_download_manager, await_background_tasks):
"""Tagging a Repo (through the collections API) moves the whole clone, including `.git`."""
remote = git_remote_factory()
repo = lib.create_repository(test_session, remote.url)
await repos_download_manager.wait_for_all_downloads()
old_directory = test_directory / 'repos/example'
assert (old_directory / '.git').is_dir()
body = dict(tag_name='software', directory='repos/software/example')
request, response = await async_client.post(f'/api/collections/{repo.collection_id}/tag',
content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
await await_background_tasks()
test_session.expire_all()
new_directory = test_directory / 'repos/software/example'
assert repo.directory == new_directory
assert repo.tag_name == 'software'
assert (new_directory / '.git').is_dir()
assert (new_directory / 'README.md').is_file()
assert not old_directory.exists()
assert test_session.query(Download).one().destination == new_directory
# The moved clone still updates.
new_head = remote.commit({'new.txt': 'new'}, 'New')
download = test_session.query(Download).one()
download.renew()
test_session.commit()
await repos_download_manager.wait_for_all_downloads()
test_session.expire_all()
assert repo.head_sha == new_head
assert (new_directory / 'new.txt').is_file()
@pytest.mark.asyncio
async def test_repos_destination_setting(async_client, test_wrolpi_config):
request, response = await async_client.get('/api/settings')
assert response.json['repos_destination'] == 'repos/%(repo_tag)s/%(repo_name)s'
body = dict(repos_destination='/repos')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
body = dict(repos_destination='repos/%(repo_owner)s/%(repo_name)s')
request, response = await async_client.patch('/api/settings', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get('/api/settings')
assert response.json['repos_destination'] == 'repos/%(repo_owner)s/%(repo_name)s'