mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Some checks are pending
Backend Tests / api-tests (core) (push) Waiting to run
Backend Tests / api-tests (files) (push) Waiting to run
Backend Tests / api-tests (media) (push) Waiting to run
Backend Tests / api-tests (rest) (push) Waiting to run
Backend Tests / api-contract-tests (push) Waiting to run
Backend Tests / api-migrations-and-start (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Cypress Tests / cypress-component-tests (push) Waiting to run
Cypress Tests / cypress-e2e-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
* Benchmark every database config import (#865) An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk, writes each config with its own dump_config, then times every import in startup order: a steady pass (the database already matches) and a rebuild (config-owned rows deleted, everything restored from the configs). It prints seconds, SQL statements and writes per import, and asserts the rebuild restored every row so no timed import silently skipped its work. WROLPI_CONFIG_BENCH_SCALE multiplies the row counts. The baseline for the config import rework. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Database configs carry a version the database keeps too Step 1 of the config import rework. A database config (tags, downloads, channels, domains, playlists, repos, recently viewed) and the database now each carry an integer version, so an import can tell which side is newer without a clock (a Raspberry Pi's resets at boot): - config_version (new table) holds the database's version of each config. - An after_flush hook adds one to it for every config whose rows a flush changed, in the same transaction. Each config registers what it owns (register_ownership): models, optionally only some columns, optionally only some rows (a Collection's kind; a Download's Collection's kind). Raw SQL writes to owned tables count explicitly. - dump_config writes the database's version, read with the rows it saves; save() no longer adds one for these configs. - An import compares versions first: file == database or newer imports with deletes; file older (the database changed and was not saved before a restart) saves the database instead of undoing that change; no database row (a config this code never imported) imports without deletes. The import records the file's version last. - The Import button and backup imports apply the file whatever its version (import_forced). Importers only gate their existing deletes on the decision here; they are otherwise unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Config versions: keep unsaved rows after a no-delete import, count every owned write Review fixes: - An import without deletes (no database version yet) no longer records the file's version, which let the next startup delete the rows it had kept. The version stays unknown, and the database is saved into the file instead; a successful save records the version. A save after a partial import is refused, so the version stays unknown until an import applies the whole file. - An import removes the database's version while it runs, so its own writes do not count: one that fails partway no longer looks newer than the file and gets saved over it on the next startup. - Playlists imported without deletes keep their database items in order; the file's other items are added after their neighbor from the file. - A Download leaving a Channel or Repo counts for that Collection's config (collection_id loads its previous value). - Bulk writes count: untagging, retrying Downloads, deleting a duplicate's Download, removing many items from a playlist. Deleting Downloads by id counts channels.yaml/repos.yaml only for their Downloads. - Only a playlist's items count for playlists.yaml (author and subject Collections have items too). Collection kinds are looked up once per flush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Config versions count only the columns each config saves A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count, info_json), a Collection's counts, and a Download attempt (attempts, error) counted as config changes, so the next import of that config saved instead of importing: a hand edit to repos.yaml was overwritten after any fetch. Each model now registers only the columns its config saves. domains.yaml saves its domains' Downloads, which now count for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
8.3 KiB
Python
177 lines
8.3 KiB
Python
"""A Repo which is never updated: its clone is kept as it is (e.g. its upstream is gone), and only updated on
|
|
request."""
|
|
import json
|
|
import subprocess
|
|
from http import HTTPStatus
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from modules.repos import lib
|
|
from modules.repos.models import Repository
|
|
from wrolpi.conftest import config_versions_lost
|
|
from wrolpi.downloader import Download, DownloadFrequency, DownloadStatus
|
|
|
|
|
|
def edit_config(config, change):
|
|
"""Hand-edit each Repo in repos.yaml."""
|
|
data = yaml.safe_load(config.get_file().read_text())
|
|
data['repos'] = [change(i) for i in data['repos']]
|
|
config.get_file().write_text(yaml.dump(data))
|
|
|
|
|
|
async def get_repo(async_client, repo_id: int) -> dict:
|
|
request, response = await async_client.get(f'/api/repos/{repo_id}')
|
|
assert response.status_code == HTTPStatus.OK, response.json
|
|
return response.json['repo']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_stops_updates(test_session, test_directory, async_client, test_wrolpi_config,
|
|
git_remote_factory, repos_download_manager):
|
|
"""Changing a Repo to Never deletes its recurring Download (and its error); its clone is kept."""
|
|
remote = git_remote_factory()
|
|
repo = lib.create_repository(test_session, remote.url)
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
download = test_session.query(Download).one()
|
|
download.error = 'fatal: repository not found'
|
|
download.status = DownloadStatus.deferred
|
|
test_session.commit()
|
|
|
|
request, response = await async_client.put(f'/api/repos/{repo.id}', content=json.dumps(dict(frequency=0)))
|
|
assert response.status_code == HTTPStatus.OK, response.json
|
|
assert response.json['repo']['frequency'] == 0
|
|
assert response.json['repo']['download_error'] is None
|
|
assert test_session.query(Download).count() == 0
|
|
assert (repo.directory / '.git').is_dir()
|
|
|
|
# Back to weekly.
|
|
request, response = await async_client.put(f'/api/repos/{repo.id}',
|
|
content=json.dumps(dict(frequency=DownloadFrequency.weekly)))
|
|
assert response.status_code == HTTPStatus.OK, response.json
|
|
assert response.json['repo']['frequency'] == DownloadFrequency.weekly
|
|
download = test_session.query(Download).one()
|
|
assert download.frequency == DownloadFrequency.weekly
|
|
assert download.collection_id == repo.collection_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_new_repo_clones_once(test_session, test_directory, async_client, test_wrolpi_config,
|
|
git_remote_factory, repos_download_manager):
|
|
"""A new Repo which is never updated is cloned once."""
|
|
remote = git_remote_factory()
|
|
body = dict(url=remote.url, frequency=0)
|
|
request, response = await async_client.post('/api/repos', content=json.dumps(body))
|
|
assert response.status_code == HTTPStatus.CREATED, response.json
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
test_session.expire_all()
|
|
|
|
repo = test_session.query(Repository).one()
|
|
assert repo.head_sha == remote.head
|
|
assert repo.download is None
|
|
download = test_session.query(Download).one()
|
|
assert download.frequency is None and download.collection_id is None
|
|
assert download.status == DownloadStatus.complete
|
|
assert (await get_repo(async_client, repo.id))['frequency'] == 0
|
|
|
|
# Back to weekly; the clone's (one-time) Download becomes the recurring Download.
|
|
request, response = await async_client.put(f'/api/repos/{repo.id}',
|
|
content=json.dumps(dict(frequency=DownloadFrequency.weekly)))
|
|
assert response.status_code == HTTPStatus.OK, response.json
|
|
download = test_session.query(Download).one()
|
|
assert download.frequency == DownloadFrequency.weekly
|
|
assert download.collection_id == repo.collection_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_update_now(test_session, test_directory, async_client, test_wrolpi_config,
|
|
git_remote_factory, repos_download_manager):
|
|
"""A Repo which is never updated is updated on request, as often as requested; a failure is shown."""
|
|
remote = git_remote_factory()
|
|
repo = lib.create_repository(test_session, remote.url, frequency=0)
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
|
|
for _ in range(2):
|
|
head = remote.commit({'new.txt': remote.head}, 'New')
|
|
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
|
|
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
test_session.expire_all()
|
|
assert repo.head_sha == head
|
|
assert repo.download is None
|
|
|
|
# The upstream is gone.
|
|
subprocess.run(['rm', '-rf', str(remote.path)], check=True)
|
|
request, response = await async_client.post(f'/api/repos/{repo.id}/update')
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
test_session.expire_all()
|
|
data = await get_repo(async_client, repo.id)
|
|
assert data['download_error']
|
|
assert data['frequency'] == 0
|
|
assert repo.head_sha == head
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_import(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
|
|
repos_download_manager):
|
|
"""An imported clone which is never updated is never fetched."""
|
|
remote = git_remote_factory()
|
|
clone = test_directory / 'code/example'
|
|
subprocess.run(['git', 'clone', '--quiet', str(remote.path), str(clone)], check=True, capture_output=True)
|
|
subprocess.run(['git', '-C', str(clone), 'remote', 'set-url', 'origin', remote.url], check=True)
|
|
await lib.import_repository(test_session, 'code/example', url=remote.url, confirm=True, frequency=0)
|
|
test_session.commit()
|
|
assert test_session.query(Download).count() == 0
|
|
assert test_session.query(Repository).one().head_sha == remote.head
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_config(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
|
|
repos_download_manager):
|
|
"""Never is saved in repos.yaml. A restored Repo is cloned once if its clone is missing, and is otherwise left
|
|
as it is."""
|
|
kept, missing = git_remote_factory('kept'), git_remote_factory('missing')
|
|
lib.create_repository(test_session, kept.url, frequency=0)
|
|
lib.create_repository(test_session, missing.url, frequency=DownloadFrequency.daily)
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
config = lib.get_repos_config()
|
|
config.dump_config()
|
|
assert {i['name']: i['frequency'] for i in config.repos} == {'kept': 'never', 'missing': DownloadFrequency.daily}
|
|
|
|
# A Repo whose frequency is missing from the config is updated weekly.
|
|
edit_config(config, lambda i: {k: v for k, v in i.items() if not (i['name'] == 'missing' and k == 'frequency')})
|
|
|
|
# Lose the database, and one clone.
|
|
for repo in test_session.query(Repository).all():
|
|
lib._delete_repository(test_session, repo)
|
|
for download in test_session.query(Download).all():
|
|
test_session.delete(download)
|
|
test_session.commit()
|
|
config_versions_lost(test_session)
|
|
subprocess.run(['rm', '-rf', str(test_directory / 'repos/missing')], check=True)
|
|
|
|
config.import_config()
|
|
test_session.expire_all()
|
|
repos = {i.name: i for i in test_session.query(Repository)}
|
|
assert repos['kept'].download is None
|
|
assert repos['missing'].download.frequency == DownloadFrequency.weekly
|
|
# Only the missing clone is downloaded.
|
|
assert [i.url for i in test_session.query(Download)] == [repos['missing'].url]
|
|
|
|
# Never, in a hand-edited config.
|
|
edit_config(config, lambda i: dict(i, frequency='never'))
|
|
config.import_config()
|
|
test_session.expire_all()
|
|
assert test_session.query(Download).count() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_never_delete(test_session, test_directory, async_client, test_wrolpi_config, git_remote_factory,
|
|
repos_download_manager):
|
|
"""Deleting a Repo which is never updated deletes its one-time Download."""
|
|
remote = git_remote_factory()
|
|
repo = lib.create_repository(test_session, remote.url, frequency=0)
|
|
await repos_download_manager.wait_for_all_downloads()
|
|
assert test_session.query(Download).count() == 1
|
|
await lib.delete_repository(test_session, repo.id)
|
|
assert test_session.query(Download).count() == 0
|