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("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(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(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")
}