8.1 KiB · text History b868907
defmodule GitGud.Packages.RetentionTest do
  @moduledoc """
  What the registry keeps, what it lets go, and who gets to decide.
  """
  use GitGud.DataCase, async: false

  alias GitGud.Packages
  alias GitGud.Packages.Retention
  alias GitGud.Settings

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

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

  describe "policy_for/1" do
    test "a package with no policy of its own inherits the instance's", %{repo: repo} do
      package = package_with_versions(repo, 1)

      policy = Retention.policy_for(package)

      assert policy.mode == "keep_last"
      assert policy.keep == 24
      assert policy.source == %{mode: :instance, limit: :instance}
    end

    test "a stricter package policy wins", %{repo: repo} do
      package = package_with_versions(repo, 1)
      {:ok, package} = Retention.set_policy(package, %{"retention_keep" => "3"})

      policy = Retention.policy_for(package)

      assert policy.keep == 3
      assert policy.source.limit == :package
    end

    test "a looser package policy is refused", %{repo: repo} do
      package = package_with_versions(repo, 1)

      assert {:error, {:looser_than_instance, :keep, 24}} =
               Retention.set_policy(package, %{"retention_keep" => "100"})
    end

    test "lowering the instance ceiling tightens packages already under it", %{repo: repo} do
      package = package_with_versions(repo, 1)
      {:ok, package} = Retention.set_policy(package, %{"retention_keep" => "20"})
      assert Retention.policy_for(package).keep == 20

      # The package's own 20 is now looser than the instance's 5, so it
      # stops applying — rather than leaving a row quietly above the new
      # ceiling.
      {:ok, _} = Settings.put("packages.retention_keep", 5)

      policy = Retention.policy_for(package)
      assert policy.keep == 5
      assert policy.source.limit == :instance
    end

    test "clearing a package's policy returns it to inheriting", %{repo: repo} do
      package = package_with_versions(repo, 1)
      {:ok, package} = Retention.set_policy(package, %{"retention_keep" => "3"})
      {:ok, package} = Retention.set_policy(package, %{"retention_keep" => nil})

      assert Retention.policy_for(package).keep == 24
    end
  end

  describe "plan/1 in keep_last mode" do
    test "keeps the newest N and prunes the rest, oldest last", %{repo: repo} do
      package = package_with_versions(repo, 30)

      plan = Retention.plan(package)

      assert length(plan.keep) == 24
      assert length(plan.prune) == 6
      assert hd(plan.keep).version == "1.0.29"
      assert List.last(plan.prune).version == "1.0.0"
    end

    test "prunes nothing when there are fewer versions than the limit", %{repo: repo} do
      package = package_with_versions(repo, 5)

      assert %{prune: [], bytes: 0} = Retention.plan(package)
    end

    test "a pinned version is never pruned, however old", %{repo: repo, user: user} do
      package = package_with_versions(repo, 30)

      oldest = version(package, "1.0.0")
      {:ok, _} = Retention.pin(oldest, user)

      plan = Retention.plan(package)

      refute Enum.any?(plan.prune, &(&1.version == "1.0.0"))
      assert Enum.any?(plan.pinned, &(&1.version == "1.0.0"))
      # The pin doesn't buy the rest a reprieve: still 24 unpinned kept.
      assert length(plan.prune) == 5
    end

    test "a big enough limit keeps everything", %{repo: repo} do
      package = package_with_versions(repo, 30)
      {:ok, _} = Settings.put("packages.retention_keep", 10_000)

      assert %{prune: []} = Retention.plan(package)
    end

    test "reports the bytes it would free", %{repo: repo} do
      package = package_with_versions(repo, 26, bytes: 1_000)

      plan = Retention.plan(package)

      assert plan.bytes == 2_000
      assert plan.total_bytes == 26_000
    end
  end

  describe "plan/1 in quota mode" do
    setup do
      {:ok, _} = Settings.put("packages.retention_mode", "quota")
      {:ok, _} = Settings.put("packages.retention_bytes", 5_000)
      :ok
    end

    test "keeps the newest that fit and prunes the rest", %{repo: repo} do
      package = package_with_versions(repo, 10, bytes: 1_000)

      plan = Retention.plan(package)

      assert length(plan.keep) == 5
      assert length(plan.prune) == 5
      assert hd(plan.keep).version == "1.0.9"
    end

    test "pinned versions still consume the budget", %{repo: repo, user: user} do
      package = package_with_versions(repo, 10, bytes: 1_000)
      {:ok, _} = Retention.pin(version(package, "1.0.0"), user)

      plan = Retention.plan(package)

      # The pin eats 1000 of the 5000, so only four unpinned fit.
      assert length(plan.pinned) == 1
      assert Enum.count(plan.keep, &is_nil(&1.pinned_at)) == 4
    end

    test "never empties a package, even when one version blows the budget", %{repo: repo} do
      # 6 KiB of version against a 5 KiB budget: without the rail this
      # prunes everything and leaves a package pointing at no version.
      package = package_with_versions(repo, 3, bytes: 6_000)

      plan = Retention.plan(package)

      assert [newest] = plan.keep
      assert newest.version == "1.0.2"
      assert length(plan.prune) == 2
    end

    test "a package may claim less than the instance allows, not more", %{repo: repo} do
      package = package_with_versions(repo, 10, bytes: 1_000)

      assert {:error, {:looser_than_instance, :bytes, 5_000}} =
               Retention.set_policy(package, %{"retention_bytes" => "9000"})

      {:ok, package} = Retention.set_policy(package, %{"retention_bytes" => "2000"})
      assert length(Retention.plan(package).keep) == 2
    end
  end

  describe "pins" do
    test "are capped by the instance setting", %{repo: repo, user: user} do
      package = package_with_versions(repo, 20)

      {:ok, _} = Settings.put("packages.max_pins", 2)

      assert {:ok, _} = Retention.pin(version(package, "1.0.0"), user)
      assert {:ok, _} = Retention.pin(version(package, "1.0.1"), user)
      assert {:error, {:pin_limit, 2}} = Retention.pin(version(package, "1.0.2"), user)

      assert Retention.pin_budget(package.id) == %{used: 2, limit: 2}
    end

    test "pinning twice is a no-op, and unpinning frees the budget", %{repo: repo, user: user} do
      package = package_with_versions(repo, 3)
      {:ok, _} = Settings.put("packages.max_pins", 1)

      {:ok, pinned} = Retention.pin(version(package, "1.0.0"), user)
      assert {:ok, ^pinned} = Retention.pin(pinned, user)
      assert {:error, {:pin_limit, 1}} = Retention.pin(version(package, "1.0.1"), user)

      {:ok, _} = Retention.unpin(pinned)
      assert {:ok, _} = Retention.pin(version(package, "1.0.1"), user)
    end

    test "the default cap is 12", %{repo: repo} do
      package = package_with_versions(repo, 1)
      assert Retention.pin_budget(package.id).limit == 12
    end
  end

  describe "enforcement" do
    test "is on until an admin turns it off" do
      assert Retention.enforced?()

      user = user_fixture()
      {:ok, _} = Settings.put("packages.retention_enforced", false, user)

      refute Retention.enforced?()
    end
  end

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

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

    for i <- 0..(count - 1) do
      {:ok, _package, _version, _file} =
        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",
          # Distinct, ordered timestamps: "newest" has to be
          # deterministic for any of this to be testable.
          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
    GitGud.Packages.PackageVersion
    |> GitGud.Repo.get_by!(package_id: package.id, version: version)
    |> GitGud.Repo.preload(:files)
  end
end