package api
import (
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
func TestTwoPromotionsOfOneLocalTicketAtOnceWriteOneTicketArtifact(t *testing.T) {
ix, z, _ := attachStore(t)
db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
local, err := db.LocalTicketNew("test", "lou", "", map[string]string{"title": "promote me once", "status": "Open"})
if err != nil {
t.Fatal(err)
}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z}}), Local: db}); err != nil {
t.Fatal(err)
}
const n = 2
var start, done sync.WaitGroup
start.Add(1)
ids := make([]string, n)
errs := make([]error, n)
for i := 0; i < n; i++ {
done.Add(1)
go func(i int) {
defer done.Done()
start.Wait()
out, err := r.Invoke(as("adm", "a"), "ticket.promote", ticketPromoteIn{Repo: "test", ID: local[:12]})
if err == nil {
ids[i] = out.(ticketPromoteOut).ID
}
errs[i] = err
}(i)
}
start.Done()
done.Wait()
written := map[string]bool{}
var refused []error
for i := range ids {
if errs[i] == nil {
written[ids[i]] = true
} else {
refused = append(refused, errs[i])
}
}
if len(written) != 1 || len(refused) != n-1 {
t.Fatalf("FAIL: %d concurrent promotions wrote ticket artifacts %v and refused %v; want one artifact and one refusal", n, written, refused)
}
t.Logf("ok: one promotion wrote the ticket artifact")
if !errors.Is(refused[0], ops.ErrInvalid) {
t.Fatalf("FAIL: the losing promotion failed with %v, want a refusal", refused[0])
}
t.Logf("ok: the other was refused: %v", refused[0])
for id := range written {
if to, ok := db.LocalTicketRedirect("test", local[:12]); !ok || to != id {
t.Fatalf("FAIL: the local id redirects to %q (%v), want the one artifact %s", to, ok, id)
}
}
t.Logf("ok: the local id redirects to the one artifact")
}
// staleProbe is the local store as a losing promotion sees it: its
// redirect probe ran before the winner's tombstone, so it answers "not
// promoted" until the ticket itself has been read, by which time the
// tombstone has landed and the ticket folds to nothing.
type staleProbe struct {
*oltp.DB
read bool
}
func (s *staleProbe) LocalTicketRedirect(repo, prefix string) (string, bool) {
if !s.read {
return "", false
}
return s.DB.LocalTicketRedirect(repo, prefix)
}
func (s *staleProbe) LocalTicketByPrefix(repo, prefix string) (oltp.LocalTicket, bool, error) {
s.read = true
return s.DB.LocalTicketByPrefix(repo, prefix)
}
func TestAPromotionThatLosesAfterTheWinnersTombstoneIsToldWhereItWent(t *testing.T) {
db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
local, err := db.LocalTicketNew("test", "lou", "", map[string]string{"title": "promote me once"})
if err != nil {
t.Fatal(err)
}
// The winner: claimed, written, tombstoned.
claim, _, err := db.LocalTicketClaim("test", local)
if err != nil {
t.Fatal(err)
}
if err := db.LocalTicketPromoted("test", local, "f0551171f055", claim); err != nil {
t.Fatal(err)
}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t)), Local: &staleProbe{DB: db}}); err != nil {
t.Fatal(err)
}
_, err = r.Invoke(as("adm", "a"), "ticket.promote", ticketPromoteIn{Repo: "test", ID: local[:12]})
if errors.Is(err, ops.ErrNotFound) || !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: the losing promotion failed with %v, want the already-promoted refusal", err)
}
if !strings.Contains(err.Error(), "f0551171f055") {
t.Fatalf("FAIL: the refusal %q does not name the promoted ticket", err)
}
t.Logf("ok: the loser is refused with where the ticket went: %v", err)
}