Skip to content
quarry Artifact [b70f6f9086]
Login

Artifact [b70f6f9086]

Artifact b70f6f90862e7ef0906d500a87fa804ee663893408fcfdbde7694856076d8cd6:


package ingest

import (
	"encoding/json"
	"testing"
	"time"

	"github.com/nats-io/nats.go"

	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/index"
)

func kinds(rs []raised) []evt.Kind {
	out := make([]evt.Kind, 0, len(rs))
	for _, r := range rs {
		out = append(out, r.Kind)
	}
	return out
}

func TestDeriveReadsTheTagOperator(t *testing.T) {
	for _, tc := range []struct {
		name  string
		art   *artifact.Artifact
		prior index.Prior
		want  []evt.Kind
	}{
		// A check-in's own sym- tag raises nothing extra: branch.moved
		// for that check-in comes from the computed branch after the
		// fold, and raising it here too would double it.
		{"a check-in on a branch", &artifact.Artifact{
			Type: artifact.Manifest,
			Tags: []artifact.Tag{{Name: "*sym-trunk"}},
		}, index.Prior{}, []evt.Kind{evt.CheckinCreated}},

		{"a check-in with a plain tag", &artifact.Artifact{
			Type: artifact.Manifest,
			Tags: []artifact.Tag{{Name: "*sym-trunk"}, {Name: "+release"}},
		}, index.Prior{}, []evt.Kind{evt.CheckinCreated, evt.TagCreated}},

		{"a control artifact naming a branch", &artifact.Artifact{
			Type: artifact.Control,
			Tags: []artifact.Tag{{Name: "*sym-feature", Target: "abc"}},
		}, index.Prior{}, []evt.Kind{evt.BranchCreated}},

		{"a control artifact cancelling a tag", &artifact.Artifact{
			Type: artifact.Control,
			Tags: []artifact.Tag{{Name: "-release", Target: "abc"}},
		}, index.Prior{}, []evt.Kind{evt.TagRemoved}},

		// Cancelling a branch tag is not closing the branch, and nothing
		// in the vocabulary names it — so it raises nothing rather than
		// something approximate.
		{"a cancelled branch tag", &artifact.Artifact{
			Type: artifact.Control,
			Tags: []artifact.Tag{{Name: "-sym-old", Target: "abc"}},
		}, index.Prior{}, nil},

		// Everything with an op behind it is raised at that op, which
		// knows the previous state the artifact does not carry.
		{"a new wiki page", &artifact.Artifact{
			Type: artifact.Wiki, WikiTitle: "Home", Body: []byte("hi"),
		}, index.Prior{}, []evt.Kind{evt.WikiCreated}},

		{"an edit to a known page", &artifact.Artifact{
			Type: artifact.Wiki, WikiTitle: "Home", Body: []byte("hi"),
		}, index.Prior{WikiKnown: map[string]bool{"Home": true}}, []evt.Kind{evt.WikiEdited}},

		{"an empty body deletes the page", &artifact.Artifact{
			Type: artifact.Wiki, WikiTitle: "Home",
		}, index.Prior{WikiKnown: map[string]bool{"Home": true}}, []evt.Kind{evt.WikiDeleted}},

		// A thread opener has no G card; that is the whole distinction,
		// and it needs no index state.
		{"a forum thread", &artifact.Artifact{
			Type: artifact.Forum, ThreadTitle: "hello",
		}, index.Prior{}, []evt.Kind{evt.ForumPosted}},

		{"a forum reply", &artifact.Artifact{
			Type: artifact.Forum, ThreadRoot: "root", InReplyTo: "root",
		}, index.Prior{}, []evt.Kind{evt.ForumReplied}},

		{"a landing opener", &artifact.Artifact{
			Type: artifact.Forum, ThreadTitle: "land it",
			Body: []byte("please land\n\nzit-landing: 1\n"),
		}, index.Prior{}, []evt.Kind{evt.LandingOpened}},

		// A reply cannot tell from its own body which kind of thread it
		// is in — only the opener carries the marker.
		{"a comment on a landing", &artifact.Artifact{
			Type: artifact.Forum, ThreadRoot: "root", Body: []byte("looks good"),
		}, index.Prior{LandingThreads: map[string]bool{"root": true}},
			[]evt.Kind{evt.LandingCommented}},

		// A verdict claims a state; the fold computes it. Named after.
		{"a verdict reply", &artifact.Artifact{
			Type: artifact.Forum, ThreadRoot: "root",
			Body: []byte("zit-landing-state: accepted\n"),
		}, index.Prior{LandingThreads: map[string]bool{"root": true}}, nil},

		{"a ticket change", &artifact.Artifact{
			Type: artifact.Ticket, TicketID: "t1",
		}, index.Prior{}, nil},

		{"a malformed tag name", &artifact.Artifact{
			Type: artifact.Control,
			Tags: []artifact.Tag{{Name: "+"}},
		}, index.Prior{}, nil},
	} {
		got := kinds(derive("h1", tc.art, tc.prior))
		if len(got) != len(tc.want) {
			t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
			continue
		}
		for i := range got {
			if got[i] != tc.want[i] {
				t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
				break
			}
		}
	}
}

func TestDeriveCarriesTheBranchAndTarget(t *testing.T) {
	rs := derive("h1", &artifact.Artifact{
		Type: artifact.Manifest, User: "dan", Comment: "fix it\n\nmore",
		Tags: []artifact.Tag{{Name: "*sym-feature"}},
	}, index.Prior{})
	if got := rs[0].Payload["branch"]; got != "feature" {
		t.Errorf("checkin branch = %v, want feature", got)
	}
	if got := rs[0].Payload["summary"]; got != "fix it" {
		t.Errorf("summary = %v, want the first line only", got)
	}
	rs = derive("ctl", &artifact.Artifact{
		Type: artifact.Control,
		Tags: []artifact.Tag{{Name: "+release", Target: "target-hash"}},
	}, index.Prior{})
	if got := rs[0].Payload["target"]; got != "target-hash" {
		t.Errorf("target = %v, want the artifact the tag applies to", got)
	}
}

// The pipeline end to end: a real store, a real fold, and the events a
// dagnats subject trigger would actually see.
//
// The bootstrap round is drained FIRST and deliberately unasserted. A
// first sync rebuilds from the store's census rather than the bus, and a
// rebuild is not news — `checkin.created` for a history that already
// existed would be the forge telling every subscriber that five years of
// commits just landed.
func TestFoldRaisesDerivedEventsOnce(t *testing.T) {
	pipe, _, repo := testPipeline(t)
	// Twice: the first round rebuilds from the census (no cursor), and
	// the second settles the once-per-process tip check. Only after that
	// does a round take the bus, which is the path under test.
	for i := 0; i < 2; i++ {
		if _, err := pipe.Sync(); err != nil {
			t.Fatal(err)
		}
	}

	sub, err := pipe.ev.JS().SubscribeSync("quarry.evt.test.>", nats.DeliverNew())
	if err != nil {
		t.Fatal(err)
	}
	defer sub.Unsubscribe()

	commitFile(t, repo, "c.txt", "gamma\n", "add c")
	commitFile(t, repo, "d.txt", "delta\n", "add d")
	if _, err := pipe.Sync(); err != nil {
		t.Fatal(err)
	}
	drain := func() map[evt.Kind]int {
		seen := map[evt.Kind]int{}
		for {
			m, err := sub.NextMsg(400 * time.Millisecond)
			if err != nil {
				return seen
			}
			var e struct {
				Kind evt.Kind
				Repo string
			}
			if err := json.Unmarshal(m.Data, &e); err != nil {
				t.Fatalf("body: %v", err)
			}
			if e.Repo != "test" {
				t.Errorf("event carried repo %q", e.Repo)
			}
			seen[e.Kind]++
		}
	}
	first := drain()
	if first[evt.CheckinCreated] != 2 {
		t.Fatalf("two new check-ins produced %d checkin.created (%v)", first[evt.CheckinCreated], first)
	}
	if first[evt.BranchMoved] == 0 {
		t.Errorf("no branch.moved for check-ins on trunk (%v)", first)
	}

	// Folding again must raise NOTHING. Folds are idempotent; raising is
	// not, and at-least-once redelivery would otherwise tell every
	// subscriber the history had just happened again.
	if _, err := pipe.Sync(); err != nil {
		t.Fatal(err)
	}
	if again := drain(); len(again) != 0 {
		t.Errorf("a refold re-raised %v", again)
	}

	// A genuinely new check-in still raises.
	commitFile(t, repo, "e.txt", "epsilon\n", "add e")
	if _, err := pipe.Sync(); err != nil {
		t.Fatal(err)
	}
	if third := drain(); third[evt.CheckinCreated] != 1 {
		t.Errorf("a new check-in raised %v", third)
	}
}

// The gap RFC 0005 named, closed and proved: a ticket changed with no
// forge op behind it still raises ticket.closed rather than a shrug.
//
// The pipeline's own zit client IS the push case — these artifacts are
// made by the CLI, exactly as a push from a clone delivers them, with no
// api op anywhere. The status before the change lives only in the index,
// which is why reading it there is what makes this work.
func TestPushedTicketChangeNamesItsTransition(t *testing.T) {
	pipe, _, _ := testPipeline(t)
	for i := 0; i < 2; i++ {
		if _, err := pipe.Sync(); err != nil {
			t.Fatal(err)
		}
	}
	sub, err := pipe.ev.JS().SubscribeSync("quarry.evt.test.ticket.*", nats.DeliverNew())
	if err != nil {
		t.Fatal(err)
	}
	defer sub.Unsubscribe()

	next := func() evt.Kind {
		t.Helper()
		m, err := sub.NextMsg(3 * time.Second)
		if err != nil {
			t.Fatalf("expected a ticket event: %v", err)
		}
		var e struct{ Kind evt.Kind }
		if err := json.Unmarshal(m.Data, &e); err != nil {
			t.Fatal(err)
		}
		return e.Kind
	}

	id, _, err := pipe.cli.TicketNew(testToken, "dan", map[string]string{
		"title": "it breaks", "status": "Open",
	})
	if err != nil {
		t.Skipf("zit ticket unavailable: %v", err)
	}
	if _, err := pipe.Sync(); err != nil {
		t.Fatal(err)
	}
	if got := next(); got != evt.TicketOpened {
		t.Fatalf("a new ticket raised %s, want ticket.opened", got)
	}

	for _, tc := range []struct {
		status string
		want   evt.Kind
	}{
		{"Closed", evt.TicketClosed},
		{"Open", evt.TicketReopened},
	} {
		if _, err := pipe.cli.TicketSet(testToken, id, "dan", map[string]string{"status": tc.status}); err != nil {
			t.Fatal(err)
		}
		if _, err := pipe.Sync(); err != nil {
			t.Fatal(err)
		}
		if got := next(); got != tc.want {
			t.Errorf("status -> %s raised %s, want %s", tc.status, got, tc.want)
		}
	}

	// A change that touches something else is neither: the transition is
	// the closed-ness, not the edit.
	if _, err := pipe.cli.TicketSet(testToken, id, "dan", map[string]string{"title": "it still breaks"}); err != nil {
		t.Fatal(err)
	}
	if _, err := pipe.Sync(); err != nil {
		t.Fatal(err)
	}
	if got := next(); got != evt.TicketChanged {
		t.Errorf("a title edit raised %s, want ticket.changed", got)
	}
}

// An A card with a source attaches a file; one without is fossil's
// deletion of that name on that target, and the two are different news.
func TestDeriveNamesAttachmentEvents(t *testing.T) {
	added := derive("ah1", &artifact.Artifact{
		Type: artifact.Attachment, User: "dan", Date: "2026-01-01T00:00:00",
		Attachment: &artifact.Attach{Name: "shot.png", Target: "tkt", Source: "blob1"},
	}, index.Prior{})
	if len(added) != 1 || added[0].Kind != evt.AttachmentAdded {
		t.Fatalf("FAIL: attach raised %v", kinds(added))
	}
	if added[0].Payload["target"] != "tkt" || added[0].Payload["name"] != "shot.png" {
		t.Fatalf("FAIL: payload lacks its subject: %+v", added[0].Payload)
	}
	removed := derive("ah2", &artifact.Artifact{
		Type: artifact.Attachment, User: "dan", Date: "2026-01-02T00:00:00",
		Attachment: &artifact.Attach{Name: "shot.png", Target: "tkt"},
	}, index.Prior{})
	if len(removed) != 1 || removed[0].Kind != evt.AttachmentRemoved {
		t.Fatalf("FAIL: a sourceless card raised %v", kinds(removed))
	}
	// A card quarry could not parse into an A record says nothing.
	if got := derive("ah3", &artifact.Artifact{Type: artifact.Attachment}, index.Prior{}); len(got) != 0 {
		t.Fatalf("FAIL: an A card with no record raised %v", kinds(got))
	}
	t.Log("ok: attachment events carry target and name, deletion included")
}