Source code for tests.test_github_caches

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests of the GitHub Actions cache listing and pruning."""

from __future__ import annotations

from unittest.mock import patch

import pytest
from click_extra.testing import CliRunner

from repomatic.cli.main import repomatic
from repomatic.github import caches as caches_module
from repomatic.github.caches import (
    ActionsCache,
    PruneReport,
    list_caches,
    prune_caches,
)
from repomatic.github.gh import GhCommandError

REPO = "orchard/papaya"


def _item(cache_id: int, key: str, created_at: str, size: int = 1024) -> dict:
    """One cache entry as the REST API lists it."""
    return {
        "id": cache_id,
        "key": key,
        "ref": "refs/heads/main",
        "size_in_bytes": size,
        "created_at": created_at,
        "last_accessed_at": created_at,
    }


LISTING = [
    _item(1, "nuitka-linux-x64-aaa", "2026-10-01T10:00:00Z"),
    _item(3, "nuitka-linux-x64-ccc", "2026-10-03T10:00:00Z", size=2048),
    _item(2, "nuitka-linux-x64-bbb", "2026-10-02T10:00:00Z"),
    # A key the prefix does not cover, as a lax server-side match could return.
    _item(9, "nuitka-linux-arm64-zzz", "2026-10-04T10:00:00Z"),
]


[docs] @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_list_caches_filters_on_the_prefix_and_sorts_newest_first(mock_pages): caches = list_caches(REPO, "nuitka-linux-x64-") assert [cache.id for cache in caches] == [3, 2, 1] endpoint = mock_pages.call_args[0][0] assert endpoint.startswith(f"repos/{REPO}/actions/caches?key=nuitka-linux-x64-") assert mock_pages.call_args[1] == {"key": "actions_caches"}
[docs] @patch("repomatic.github.caches.run_gh_command") @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_prune_keeps_the_newest_and_deletes_the_rest(mock_pages, mock_run): report = prune_caches(REPO, "nuitka-linux-x64-", keep=1) assert [cache.id for cache in report.kept] == [3] assert [cache.id for cache in report.deleted] == [2, 1] assert report.refused == () assert report.freed == 2048 deleted_endpoints = [call[0][0][-1] for call in mock_run.call_args_list] assert deleted_endpoints == [ f"repos/{REPO}/actions/caches/2", f"repos/{REPO}/actions/caches/1", ] for call in mock_run.call_args_list: assert call[0][0][:3] == ["api", "--method", "DELETE"]
[docs] @patch("repomatic.github.caches.run_gh_command") @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_prune_dry_run_deletes_nothing(mock_pages, mock_run): report = prune_caches(REPO, "nuitka-linux-x64-", keep=1, dry_run=True) assert [cache.id for cache in report.deleted] == [2, 1] mock_run.assert_not_called() assert [row[-1] for row in report.rows(dry_run=True)] == [ "kept", "would delete", "would delete", ]
[docs] @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_prune_reports_a_refused_deletion_without_raising(mock_pages, caplog): """A token without `actions: write` leaves the entry and the job alive.""" def refuse(args): if args[-1].endswith("/2"): raise GhCommandError("HTTP 403: Resource not accessible by integration") return "" with patch.object(caches_module, "run_gh_command", side_effect=refuse): report = prune_caches(REPO, "nuitka-linux-x64-", keep=1) assert [cache.id for cache in report.refused] == [2] assert [cache.id for cache in report.deleted] == [1] assert "Could not delete cache 'nuitka-linux-x64-bbb'" in caplog.text
[docs] @pytest.mark.parametrize("keep", [0, 2, 5]) @patch("repomatic.github.caches.run_gh_command") @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_prune_keep_bounds(mock_pages, mock_run, keep): report = prune_caches(REPO, "nuitka-linux-x64-", keep=keep) assert len(report.kept) == min(keep, 3) assert len(report.deleted) == 3 - len(report.kept)
[docs] def test_report_rows_name_every_entry_once(): cache = ActionsCache(id=1, key="k", ref="refs/heads/main", size=10, created_at="t") report = PruneReport(prefix="k", kept=(cache,), deleted=(), refused=(cache,)) rows = report.rows() assert [row[0] for row in rows] == ["k", "k"] assert [row[-1] for row in rows] == ["kept", "refused"]
[docs] @patch("repomatic.github.caches.run_gh_command") @patch("repomatic.github.caches.gh_api_pages", return_value=LISTING) def test_cli_prunes_each_prefix(mock_pages, mock_run): result = CliRunner().invoke( repomatic, [ "prune-caches", "--repo", REPO, "--prefix", "nuitka-linux-x64-", "--keep", "2", ], ) assert result.exit_code == 0, result.output assert "nuitka-linux-x64-aaa" in result.output assert "deleted" in result.output assert mock_run.call_count == 1
[docs] @patch("repomatic.github.caches.run_gh_command") @patch( "repomatic.github.caches.gh_api_pages", side_effect=GhCommandError("HTTP 403: Resource not accessible by integration"), ) def test_cli_warns_when_the_listing_is_refused(mock_pages, mock_run): """An unreadable listing is a warning, not a failed job.""" result = CliRunner().invoke( repomatic, ["prune-caches", "--repo", REPO, "--prefix", "nuitka-linux-x64-"], ) assert result.exit_code == 0, result.output assert "Could not list the caches" in result.output mock_run.assert_not_called()