5.4 KiB · text History 5ade69f
defmodule GitGud.Packages.PrunerTest do
  @moduledoc """
  Deleting bytes: what goes, what is spared, and what happens when the
  thing holding the bytes says no.
  """
  use GitGud.DataCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.PackageFile
  alias GitGud.Packages.PackageVersion
  alias GitGud.Packages.Pruner
  alias GitGud.Packages.Retention
  alias GitGud.Settings
  alias GitGud.Storage

  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture()
    %{user: user, repo: repo}
  end

  describe "prune/2 while enforcement is off" do
    setup do
      {:ok, _} = Settings.put("packages.retention_enforced", false)
      :ok
    end

    test "reports what it would do and touches nothing", %{repo: repo} do
      package = package_with_versions(repo, 30)

      assert {:ok, result} = Pruner.prune(package)

      assert result.status == :reported
      assert result.pruned == 6
      assert result.bytes > 0

      assert version_count(package) == 30
    end

    test "force overrides it, which is what the admin button does", %{repo: repo} do
      package = package_with_versions(repo, 26)

      assert {:ok, %{status: :pruned, pruned: 2}} = Pruner.prune(package, force: true)
      assert version_count(package) == 24
    end
  end

  describe "prune/2 with enforcement on" do
    test "deletes the oldest versions and their bytes", %{repo: repo} do
      package = package_with_versions(repo, 26)

      doomed = version(package, "1.0.0")
      [file] = doomed.files
      assert {:ok, _} = Storage.get(file.storage_bucket, file.storage_key)

      assert {:ok, %{status: :pruned, pruned: 2, failed: []}} = Pruner.prune(package)

      assert version_count(package) == 24
      refute Repo.get(PackageVersion, doomed.id)
      refute Repo.get(PackageFile, file.id)
      # The bytes are gone too, not just the row pointing at them.
      assert {:error, _} = Storage.get(file.storage_bucket, file.storage_key)
    end

    test "keeps bytes another version still points at", %{repo: repo} do
      # Two versions publishing byte-identical files share one
      # content-addressed object; pruning the older must not pull the
      # object out from under the newer.
      name = "shared#{System.unique_integer([:positive])}"
      body = "identical bytes\n"

      for i <- 0..25 do
        {:ok, _, _, _} =
          Packages.publish_file(repo, "pypi", name, "1.0.#{i}", %{
            filename: "#{name}-1.0.#{i}.tar.gz",
            body: body,
            content_type: "application/gzip",
            pushed_at: DateTime.add(~U[2026-01-01 00:00:00Z], i, :second)
          })
      end

      package = Packages.get_package(repo, "pypi", name)
      [survivor_file] = version(package, "1.0.25").files

      assert {:ok, %{pruned: 2}} = Pruner.prune(package)

      assert {:ok, ^body} = Storage.get(survivor_file.storage_bucket, survivor_file.storage_key)
    end

    test "a pinned version survives and the next-oldest goes instead", %{repo: repo, user: user} do
      package = package_with_versions(repo, 26)
      {:ok, _} = Retention.pin(version(package, "1.0.0"), user)

      assert {:ok, %{pruned: 1}} = Pruner.prune(package)

      assert version(package, "1.0.0")
      refute Repo.get_by(PackageVersion, package_id: package.id, version: "1.0.1")
    end

    test "latest_version is recomputed, never left dangling", %{repo: repo} do
      {:ok, _} = Settings.put("packages.retention_mode", "quota")
      {:ok, _} = Settings.put("packages.retention_bytes", 100)

      package = package_with_versions(repo, 5, bytes: 1_000)

      assert {:ok, %{status: :pruned}} = Pruner.prune(package)

      package = Repo.reload(package)
      assert package.latest_version == "1.0.4"
      assert version_count(package) == 1
    end

    test "prune_all walks every package", %{repo: repo} do
      package_with_versions(repo, 26)
      package_with_versions(repo, 27)

      assert {:ok, %{status: :pruned, pruned: 5}} = Pruner.prune_all()
    end

    test "prune_all can be scoped to one repository", %{repo: repo, user: user} do
      {_user, other} = repository_fixture(%{owner: user})
      package_with_versions(repo, 26)
      package_with_versions(other, 30)

      assert {:ok, %{pruned: 2}} = Pruner.prune_all(repository: repo)
      # The other repo is untouched.
      assert {:ok, %{pruned: 6}} = Pruner.prune_all(repository: other)
    end
  end

  # ── helpers ──────────────────────────────────────────────────────────

  defp package_with_versions(repo, count, opts \\ []) do
    bytes = Keyword.get(opts, :bytes, 32)
    name = "widget#{System.unique_integer([:positive])}"

    for i <- 0..(count - 1) do
      {:ok, _, _, _} =
        Packages.publish_file(repo, "pypi", name, "1.0.#{i}", %{
          filename: "#{name}-1.0.#{i}.tar.gz",
          body: :crypto.strong_rand_bytes(bytes),
          content_type: "application/gzip",
          pushed_at: DateTime.add(~U[2026-01-01 00:00:00Z], i, :second)
        })
    end

    Packages.get_package(repo, "pypi", name)
  end

  defp version(package, version) do
    PackageVersion
    |> Repo.get_by!(package_id: package.id, version: version)
    |> Repo.preload(:files)
  end

  defp version_count(package) do
    Repo.aggregate(
      from(v in PackageVersion, where: v.package_id == ^package.id),
      :count
    )
  end
end