5.3 KiB · text History ad37081
defmodule GitGud.Workflows.ScheduleTest do
  @moduledoc """
  `on: schedule`: crons are read from the default branch as it moves, and
  the minute tick fires a due one once.
  """
  use GitGud.DataCase, async: false
  use Oban.Testing, repo: GitGud.Repo

  alias GitGud.Repositories
  alias GitGud.Repositories.Repository
  alias GitGud.Workflows.Schedules
  alias GitGud.Workflows.WorkflowRun
  alias GitGud.Workflows.WorkflowSchedule
  alias GitGud.Workflows.Workers.ScheduleTick

  import GitGud.ForgeFixtures
  import GitGud.WorkflowGitHelpers

  @moduletag :capture_log

  @wf ".forgejo/workflows/nightly.yml"

  defp workflow(crons) do
    entries = Enum.map_join(crons, "", &"    - cron: \"#{&1}\"\n")

    """
    name: nightly
    on:
      schedule:
    #{entries}
    jobs:
      build:
        runs-on: self-hosted
        steps:
          - run: echo hello
    """
  end

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

  defp schedules(%Repository{id: rid}) do
    from(s in WorkflowSchedule, where: s.repository_id == ^rid, order_by: s.cron)
    |> Repo.all()
    |> Enum.map(&{&1.workflow_path, &1.cron})
  end

  defp scheduled_runs(%Repository{id: rid}) do
    from(r in WorkflowRun, where: r.repository_id == ^rid and r.trigger == "schedule")
    |> Repo.all()
  end

  defp tick!(at), do: :ok = perform_job(ScheduleTick, %{"at" => DateTime.to_iso8601(at)})

  describe "sync" do
    test "a push to the default branch records its crons; another branch doesn't", %{
      repo: repo
    } do
      main = commit!(repo, %{@wf => workflow(["0 3 * * *", "30 12 * * 1"])}, [])
      push_ref!(repo, "refs/heads/main", nil, main)

      assert schedules(repo) == [{@wf, "0 3 * * *"}, {@wf, "30 12 * * 1"}]

      other = commit!(repo, %{@wf => workflow(["0 4 * * *"])}, [main])
      push_ref!(repo, "refs/heads/feature", nil, other)
      assert schedules(repo) == [{@wf, "0 3 * * *"}, {@wf, "30 12 * * 1"}]
    end

    test "dropping a cron from the file removes it", %{repo: repo} do
      main = commit!(repo, %{@wf => workflow(["0 3 * * *", "30 12 * * 1"])}, [])
      push_ref!(repo, "refs/heads/main", nil, main)

      next = commit!(repo, %{@wf => workflow(["30 12 * * 1"])}, [main])
      push_ref!(repo, "refs/heads/main", main, next)
      assert schedules(repo) == [{@wf, "30 12 * * 1"}]

      gone = commit!(repo, %{"README" => "x\n"}, [next])
      push_ref!(repo, "refs/heads/main", next, gone)
      assert schedules(repo) == []
    end

    test "changing the default branch re-reads them", %{repo: repo} do
      main = commit!(repo, %{"README" => "x\n"}, [])
      push_ref!(repo, "refs/heads/main", nil, main)
      dev = commit!(repo, %{@wf => workflow(["0 3 * * *"])}, [main])
      push_ref!(repo, "refs/heads/dev", nil, dev)
      assert schedules(repo) == []

      {:ok, _repo} = Repositories.update_repository(repo, %{"default_branch" => "dev"})
      assert schedules(repo) == [{@wf, "0 3 * * *"}]
    end

    test "too frequent, @reboot and unparseable crons are refused", %{repo: repo} do
      crons = ["* * * * *", "*/2 * * * *", "58 * * * *", "@reboot", "nope", "*/5 * * * *"]
      main = commit!(repo, %{@wf => workflow(crons)}, [])
      push_ref!(repo, "refs/heads/main", nil, main)

      assert schedules(repo) == [{@wf, "*/5 * * * *"}, {@wf, "58 * * * *"}]
    end
  end

  describe "parse/1" do
    test "the five-minute minimum" do
      assert {:ok, _} = Schedules.parse("*/5 * * * *")
      assert {:ok, _} = Schedules.parse("@hourly")
      assert {:error, _} = Schedules.parse("0 0 31 2 *")
      assert {:error, _} = Schedules.parse("*/4 * * * *")
      assert {:error, _} = Schedules.parse("0,3 * * * *")
      # 12:58 then 13:01.
      assert {:error, _} = Schedules.parse("1,58 12,13 * * *")
      assert {:ok, _} = Schedules.parse("1,58 12,14 * * *")
    end
  end

  describe "the tick" do
    setup %{repo: repo} do
      main = commit!(repo, %{@wf => workflow(["0 3 * * *"])}, [])
      push_ref!(repo, "refs/heads/main", nil, main)
      {:ok, main: main}
    end

    test "fires a due schedule once a minute, on the default branch", %{repo: repo, main: main} do
      tick!(~U[2026-10-17 03:00:05Z])
      tick!(~U[2026-10-17 03:00:40Z])

      assert [run] = scheduled_runs(repo)
      assert run.head_ref == "refs/heads/main"
      assert run.head_sha == sha!(main)
      assert run.workflow_path == @wf
      assert run.event_payload["schedule"] == "0 3 * * *"

      tick!(~U[2026-10-18 03:00:00Z])
      assert [_, _] = scheduled_runs(repo)
    end

    test "doesn't fire one that isn't due", %{repo: repo} do
      tick!(~U[2026-10-17 03:01:00Z])
      assert scheduled_runs(repo) == []
    end

    test "a workflow edited to drop the cron doesn't fire it", %{repo: repo, main: main} do
      # The row is left behind (as if sync hadn't run yet): the tick
      # re-reads the file before running it.
      edited = commit!(repo, %{@wf => workflow(["0 4 * * *"])}, [main])
      git!(repo.disk_path, ["update-ref", "refs/heads/main", edited])

      tick!(~U[2026-10-17 03:00:00Z])
      assert scheduled_runs(repo) == []
    end

    test "an archived repository doesn't run", %{repo: repo} do
      {:ok, _} = Repositories.update_repository(repo, %{"archived" => true})
      tick!(~U[2026-10-17 03:00:00Z])
      assert scheduled_runs(repo) == []
    end
  end
end