package api
import (
"context"
"errors"
"slices"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// A text file is rebuilt from the merge's regions: each answered region
// takes the reader's lines, every other region the merge's own — the
// shared stretch, the one side that changed it, or the change both made
// — and the final newline is the branch's.
func TestAnswerRegionsBuildsTheFile(t *testing.T) {
blobs := fakeBlobs{
"ours": "a\nB-ours\nc\nshared\nours-only\n",
"theirs": "a\nB-theirs\nC-theirs\nshared\n",
}
f := zitcli.MergeFile{Path: "f.txt", Kind: "text", Other: "ours", Current: "theirs",
Regions: []zitcli.MergeRegion{
{Kind: "same", Other: [2]int{0, 1}, Current: [2]int{0, 1}},
{Kind: "conflict", Other: [2]int{1, 2}, Current: [2]int{1, 2}},
{Kind: "current", Other: [2]int{2, 3}, Current: [2]int{2, 3}},
{Kind: "both", Other: [2]int{3, 4}, Current: [2]int{3, 4}},
{Kind: "other", Other: [2]int{4, 5}, Current: [2]int{4, 4}},
}}
body, err := answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 1, Lines: []string{"B-mine"}}})
if err != nil {
t.Fatal(err)
}
if want := "a\nB-mine\nC-theirs\nshared\nours-only\n"; string(body) != want {
t.Errorf("FAIL: built %q, want %q", body, want)
}
if _, err := answerRegions(context.Background(), blobs, f, nil); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: an unanswered conflict gave %v", err)
}
// Overriding a merged region is allowed: an arrow can put a side's
// old lines back, as in Meld.
body, _ = answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 1, Lines: nil}, {Index: 2, Lines: []string{"c"}}})
if want := "a\nc\nshared\nours-only\n"; string(body) != want {
t.Errorf("FAIL: built %q, want %q", body, want)
}
if _, err := answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 9}}); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: a region past the file gave %v", err)
}
}
// Every conflicting path is answered exactly once; a whole-file answer
// takes that side's bytes, and a side with no file removes the path.
func TestAnswerConflictsCoversEveryPath(t *testing.T) {
blobs := fakeBlobs{"o": "ours bytes", "c": "theirs bytes"}
rep := zitcli.MergeReport{Files: []zitcli.MergeFile{
{Path: "logo.png", Kind: "binary", Other: "o", Current: "c"},
{Path: "gone.txt", Kind: "modify-delete", Other: "", Current: "c"},
{Path: "kill.txt", Kind: "binary", Other: "o", Current: "c"},
}}
files, deletes, err := answerConflicts(context.Background(), blobs, rep, []resolveFile{
{Path: "logo.png", Take: "theirs"}, {Path: "gone.txt", Take: "ours"}, {Path: "kill.txt", Take: "delete"}})
if err != nil {
t.Fatal(err)
}
if string(files["logo.png"]) != "theirs bytes" || len(files) != 1 {
t.Errorf("FAIL: files %v", files)
}
slices.Sort(deletes)
if !slices.Equal(deletes, []string{"gone.txt", "kill.txt"}) {
t.Errorf("FAIL: deletes %v", deletes)
}
for _, bad := range [][]resolveFile{
{{Path: "logo.png", Take: "ours"}},
{{Path: "logo.png", Take: "ours"}, {Path: "logo.png", Take: "theirs"}, {Path: "kill.txt", Take: "ours"}},
{{Path: "logo.png", Take: "ours"}, {Path: "gone.txt", Take: "ours"}, {Path: "other.txt", Take: "ours"}},
{{Path: "logo.png", Take: "regions"}, {Path: "gone.txt", Take: "ours"}, {Path: "kill.txt", Take: "ours"}},
} {
if _, _, err := answerConflicts(context.Background(), blobs, rep, bad); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: answers %+v gave %v", bad, err)
}
}
}
// Resolving writes to the branch, so a protected branch's rule decides
// it as it decides a push: refused before the store is asked.
func TestResolveOnAProtectedBranchIsRefused(t *testing.T) {
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{"q": {Index: landingIndex(t, "alice", "feat", "trunk", "")}}),
Protect: stubProtect{rules: []oltp.Protection{{Branch: "feat", Owners: []string{"alice"}}}}}); err != nil {
t.Fatal(err)
}
_, err := r.Invoke(ops.WithUser(context.Background(), "mallory"), "landing.resolve",
landingResolveIn{Repo: "q", ID: landingHash, BranchTip: "b", TargetTip: "t"})
if !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: a stranger resolving onto a protected branch gave %v", err)
}
}
// A merge names two branches the index knows, and two different ones:
// a typo is not found rather than read as a clean merge.
func TestMergePairRefusesWhatIsNotAPair(t *testing.T) {
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{"q": {Index: landingIndex(t, "alice", "feat", "trunk", "")}})}); err != nil {
t.Fatal(err)
}
if _, err := r.Invoke(context.Background(), "merge.conflicts", mergePairIn{Repo: "q", Branch: "nope", Onto: "trunk"}); !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: an unknown branch gave %v", err)
}
}