Files
woodpecker/server/api/cron_test.go
2026-06-15 22:08:11 +02:00

389 lines
11 KiB
Go

// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build test
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.woodpecker-ci.org/woodpecker/v3/server"
forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/store"
)
// cronFixture seeds one repo + user into the store. It is created once per
// top-level test and shared across subtests; subtests use unique cron names so
// the per-repo unique constraint never collides.
func cronFixture(t *testing.T, s store.Store) (*model.Repo, *model.User) {
t.Helper()
user := &model.User{Login: "owner", ForgeRemoteID: "u1"}
require.NoError(t, s.CreateUser(user))
repo := &model.Repo{
UserID: user.ID,
ForgeRemoteID: "r1",
Owner: "owner",
Name: "repo",
FullName: "owner/repo",
IsActive: true,
}
require.NoError(t, s.CreateRepo(repo))
return repo, user
}
// seedCron inserts a cron directly into the store for read/update/delete tests.
func seedCron(t *testing.T, s store.Store, repoID int64, name string) *model.Cron {
t.Helper()
cron := &model.Cron{
RepoID: repoID,
Name: name,
Schedule: "@every 1h",
Timezone: "UTC",
Branch: "main",
Enabled: true,
}
require.NoError(t, s.CronCreate(cron))
return cron
}
// cronForgeManager installs a mock manager returning a fresh mock forge and
// returns the forge so tests can add expectations (e.g. BranchHead).
func cronForgeManager(t *testing.T) *forge_mocks.MockForge {
t.Helper()
mgr := manager_mocks.NewMockManager(t)
forge := forge_mocks.NewMockForge(t)
mgr.On("ForgeFromRepo", mock.Anything).Return(forge, nil)
server.Config.Services.Manager = mgr
return forge
}
func TestGetCron(t *testing.T) {
s := newTestStore(t)
repo, _ := cronFixture(t, s)
t.Run("happy path returns cron", func(t *testing.T) {
cron := seedCron(t, s, repo.ID, "nightly")
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", strItoa(cron.ID))(tc)
GetCron(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got model.Cron
tc.decodeJSON(t, &got)
assert.Equal(t, cron.ID, got.ID)
assert.Equal(t, "nightly", got.Name)
})
t.Run("invalid id returns bad request", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "not-a-number")(tc)
GetCron(tc.Ctx)
assert.Equal(t, http.StatusBadRequest, tc.Recorder.Code)
})
t.Run("missing cron returns not found", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "9999")(tc)
GetCron(tc.Ctx)
assert.Equal(t, http.StatusNotFound, tc.Recorder.Code)
})
}
func TestGetCronList(t *testing.T) {
s := newTestStore(t)
repo, _ := cronFixture(t, s)
t.Run("empty repo returns empty list", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
GetCronList(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got []*model.Cron
tc.decodeJSON(t, &got)
assert.Empty(t, got)
})
t.Run("returns all crons for repo", func(t *testing.T) {
seedCron(t, s, repo.ID, "list-a")
seedCron(t, s, repo.ID, "list-b")
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
GetCronList(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got []*model.Cron
tc.decodeJSON(t, &got)
assert.Len(t, got, 2)
})
}
func TestDeleteCron(t *testing.T) {
s := newTestStore(t)
repo, _ := cronFixture(t, s)
t.Run("happy path deletes", func(t *testing.T) {
cron := seedCron(t, s, repo.ID, "del")
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", strItoa(cron.ID))(tc)
DeleteCron(tc.Ctx)
assert.Equal(t, http.StatusNoContent, tc.Ctx.Writer.Status())
_, err := s.CronFind(repo, cron.ID)
assert.Error(t, err)
})
t.Run("invalid id returns bad request", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "abc")(tc)
DeleteCron(tc.Ctx)
assert.Equal(t, http.StatusBadRequest, tc.Recorder.Code)
})
t.Run("missing cron returns not found", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "9999")(tc)
DeleteCron(tc.Ctx)
assert.Equal(t, http.StatusNotFound, tc.Recorder.Code)
})
}
func TestPostCron(t *testing.T) {
s := newTestStore(t)
repo, user := cronFixture(t, s)
t.Run("happy path creates cron", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRequest(http.MethodPost, &model.Cron{Name: "build", Schedule: "@every 1h"})(tc)
PostCron(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got model.Cron
tc.decodeJSON(t, &got)
assert.Equal(t, "build", got.Name)
assert.Equal(t, "UTC", got.Timezone) // defaulted
assert.Positive(t, got.NextExec)
})
t.Run("validation fails on empty name", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRequest(http.MethodPost, &model.Cron{Schedule: "@every 1h"})(tc)
PostCron(tc.Ctx)
assert.Equal(t, http.StatusUnprocessableEntity, tc.Recorder.Code)
})
t.Run("malformed body returns bad request", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRawBody(http.MethodPost, "application/json", []byte("{not json"))(tc)
PostCron(tc.Ctx)
assert.Equal(t, http.StatusBadRequest, tc.Recorder.Code)
})
t.Run("unparseable schedule fails validation", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRequest(http.MethodPost, &model.Cron{Name: "x", Schedule: "totally-invalid"})(tc)
PostCron(tc.Ctx)
assert.Equal(t, http.StatusUnprocessableEntity, tc.Recorder.Code)
})
t.Run("branch checked on forge when set", func(t *testing.T) {
forge := cronForgeManager(t)
forge.On("BranchHead", mock.Anything, user, mock.Anything, "feature").
Return(&model.Commit{SHA: "abc"}, nil)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRequest(http.MethodPost, &model.Cron{Name: "br", Schedule: "@every 1h", Branch: "feature"})(tc)
PostCron(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
forge.AssertCalled(t, "BranchHead", mock.Anything, user, mock.Anything, "feature")
})
t.Run("duplicate cron returns conflict", func(t *testing.T) {
seedCron(t, s, repo.ID, "dup")
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withRequest(http.MethodPost, &model.Cron{Name: "dup", Schedule: "@every 1h"})(tc)
PostCron(tc.Ctx)
assert.Equal(t, http.StatusConflict, tc.Recorder.Code)
})
}
func TestRunCron(t *testing.T) {
s := newTestStore(t)
repo, _ := cronFixture(t, s)
t.Run("invalid id returns bad request", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "abc")(tc)
RunCron(tc.Ctx)
assert.Equal(t, http.StatusBadRequest, tc.Recorder.Code)
})
t.Run("missing cron returns not found", func(t *testing.T) {
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "9999")(tc)
RunCron(tc.Ctx)
assert.Equal(t, http.StatusNotFound, tc.Recorder.Code)
})
t.Run("pipeline creation failure returns internal error", func(t *testing.T) {
cron := seedCron(t, s, repo.ID, "run-fail")
// CreatePipeline resolves repo+user from the store, then asks the forge
// for the branch head. Make that fail so RunCron hits its 500 branch
// without exercising the full pipeline.Create machinery.
forge := cronForgeManager(t)
forge.On("BranchHead", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, assert.AnError)
tc := newTestContext(t, s)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", strItoa(cron.ID))(tc)
RunCron(tc.Ctx)
assert.Equal(t, http.StatusInternalServerError, tc.Recorder.Code)
})
}
func TestPatchCron(t *testing.T) {
s := newTestStore(t)
repo, user := cronFixture(t, s)
t.Run("happy path updates name and schedule", func(t *testing.T) {
cron := seedCron(t, s, repo.ID, "orig")
cronForgeManager(t)
newName := "renamed"
newSchedule := "@every 2h"
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", strItoa(cron.ID))(tc)
withRequest(http.MethodPatch, &model.CronPatch{Name: &newName, Schedule: &newSchedule})(tc)
PatchCron(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got model.Cron
tc.decodeJSON(t, &got)
assert.Equal(t, "renamed", got.Name)
assert.Equal(t, "@every 2h", got.Schedule)
})
t.Run("invalid id returns bad request", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "nope")(tc)
withRequest(http.MethodPatch, &model.CronPatch{})(tc)
PatchCron(tc.Ctx)
assert.Equal(t, http.StatusBadRequest, tc.Recorder.Code)
})
t.Run("missing cron returns not found", func(t *testing.T) {
cronForgeManager(t)
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", "9999")(tc)
withRequest(http.MethodPatch, &model.CronPatch{})(tc)
PatchCron(tc.Ctx)
assert.Equal(t, http.StatusNotFound, tc.Recorder.Code)
})
t.Run("branch checked on forge when patched", func(t *testing.T) {
cron := seedCron(t, s, repo.ID, "brpatch")
forge := cronForgeManager(t)
forge.On("BranchHead", mock.Anything, user, mock.Anything, "develop").
Return(&model.Commit{SHA: "abc"}, nil)
branch := "develop"
tc := newTestContext(t, s)
withUser(user)(tc)
withRepo(repo, &model.Perm{})(tc)
withParam("cron", strItoa(cron.ID))(tc)
withRequest(http.MethodPatch, &model.CronPatch{Branch: &branch})(tc)
PatchCron(tc.Ctx)
require.Equal(t, http.StatusOK, tc.Recorder.Code)
var got model.Cron
tc.decodeJSON(t, &got)
assert.Equal(t, "develop", got.Branch)
})
}