package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// engineStub answers GET /runs/{id} with run and records every cancel.
type engineStub struct {
mu sync.Mutex
run ci.Run
cancelled []string
}
func (s *engineStub) serve(t *testing.T) *ci.Engine {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if req.Method == "POST" && req.URL.Path == "/runs/"+s.run.RunID+"/cancel" {
s.cancelled = append(s.cancelled, s.run.RunID)
s.run.Status = "cancelled"
return
}
json.NewEncoder(w).Encode(s.run)
}))
t.Cleanup(srv.Close)
return ci.New(srv.URL)
}
func cancelDB(t *testing.T) *oltp.DB {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := db.AddCIRun(oltp.CIRun{RunID: "r1", Repo: "alpha", Checkin: "c1",
Workflow: "w", Pipeline: "ci/p.json", Status: "running"}); err != nil {
t.Fatal(err)
}
return db
}
func runRow(t *testing.T, db *oltp.DB) oltp.CIRun {
t.Helper()
r, ok, err := db.CIRunByID("alpha", "r1")
if err != nil || !ok {
t.Fatalf("run r1: %v %v", ok, err)
}
return r
}
// #297: a repository admin can end a run. The engine is told, the reason
// is recorded against the run, and the poller — which drains the run's
// output before marking it finished — settles it as cancelled.
func TestCancelCIRunEndsTheRunAsCancelled(t *testing.T) {
stub := &engineStub{run: ci.Run{RunID: "r1", Status: "running",
Steps: map[string]ci.Step{"test": {Status: "queued"}}}}
engine := stub.serve(t)
db := cancelDB(t)
if err := cancelCIRun(engine, db, "beta", "r1", "dan"); !errors.Is(err, ops.ErrNotFound) {
t.Fatalf("a run of another repository answered %v, want not found", err)
}
if err := cancelCIRun(engine, db, "alpha", "nope", "dan"); !errors.Is(err, ops.ErrNotFound) {
t.Fatalf("an unknown run answered %v, want not found", err)
}
if len(stub.cancelled) != 0 {
t.Fatalf("engine cancelled %v before a valid ask", stub.cancelled)
}
if err := cancelCIRun(engine, db, "alpha", "r1", "dan"); err != nil {
t.Fatal(err)
}
if len(stub.cancelled) != 1 || runRow(t, db).Reason == "" {
t.Fatalf("cancelled %v reason %q, want one engine cancel and a recorded reason", stub.cancelled, runRow(t, db).Reason)
}
pollCI(engine, db, nil, false, t.TempDir(), nil)
if r := runRow(t, db); r.Status != "cancelled" || r.Completed == "" || r.Reason == "" {
t.Fatalf("after the poll: %+v, want cancelled, completed, reason kept", r)
}
if err := cancelCIRun(engine, db, "alpha", "r1", "dan"); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("cancelling a finished run answered %v, want invalid", err)
}
}
// #297: a run whose steps no worker has taken ends after a bounded time
// as cancelled with a reason, instead of reading "running" for ever. A
// young run, and one with a step running, are left alone.
func TestPollCIExpiresARunNoWorkerHasClaimed(t *testing.T) {
old := time.Now().Add(-2 * ciUnclaimedAfter).UTC().Format(time.RFC3339Nano)
fresh := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
cases := []struct {
name string
created string
steps map[string]ci.Step
expires bool
}{
{"unclaimed and old", old, map[string]ci.Step{"test": {Status: "queued"}}, true},
{"unclaimed but young", fresh, map[string]ci.Step{"test": {Status: "queued"}}, false},
{"old with a step running", old, map[string]ci.Step{"test": {Status: "running"}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
stub := &engineStub{run: ci.Run{RunID: "r1", Status: "running", CreatedAt: c.created, Steps: c.steps}}
engine := stub.serve(t)
db := cancelDB(t)
pollCI(engine, db, nil, false, t.TempDir(), nil)
r := runRow(t, db)
if c.expires {
if len(stub.cancelled) != 1 || r.Status != "cancelled" || r.Completed == "" || r.Reason == "" {
t.Fatalf("cancels %v, row %+v: want engine cancelled, row cancelled with a reason", stub.cancelled, r)
}
return
}
if len(stub.cancelled) != 0 || r.Status != "running" || r.Reason != "" {
t.Fatalf("cancels %v, row %+v: want the run left running", stub.cancelled, r)
}
})
}
}