Skip to content
quarry Artifact [a5503bb43d]
Login

Artifact [a5503bb43d]

Artifact a5503bb43d204f9d0032bc96ccdb0aafc53e611f03ad2b5179a36227a2165a6d:


package index

import (
	"path/filepath"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/artifact"
	stub "craftdesign.group/quarry/internal/attach"
)

func openIndex(t *testing.T) *Index {
	t.Helper()
	ix, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	return ix
}

func attached(hash, name, target, src, date string) Parsed {
	return Parsed{Hash: hash, Art: &artifact.Artifact{
		Type: artifact.Attachment, Date: date, User: "dan",
		Attachment: &artifact.Attach{Name: name, Target: target, Source: src},
	}}
}

// Fossil models attachments as a log, not a set: the latest A card per
// filename wins, and one with no source is a deletion. Getting that
// wrong resurrects deleted files.
func TestAttachmentsResolveLikeFossil(t *testing.T) {
	ix := openIndex(t)
	tkt := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
	if err := ix.FoldNow([]Parsed{
		attached("h1", "spec.pdf", tkt, "src1", "2026-01-01T00:00:00"),
		attached("h2", "spec.pdf", tkt, "src2", "2026-01-02T00:00:00"), // replaced
		attached("h3", "notes.txt", tkt, "src3", "2026-01-03T00:00:00"),
		attached("h4", "notes.txt", tkt, "", "2026-01-04T00:00:00"), // deleted
		attached("h5", "other.txt", "different-target", "src5", "2026-01-05T00:00:00"),
	}); err != nil {
		t.Fatal(err)
	}
	got, err := ix.AttachmentsFor(tkt)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 {
		t.Fatalf("live attachments = %+v, want just the replaced spec.pdf", got)
	}
	if got[0].Name != "spec.pdf" || got[0].Src != "src2" {
		t.Fatalf("wrong revision won: %+v", got[0])
	}

	// A deletion followed by a re-attach brings the file back.
	if err := ix.FoldNow([]Parsed{
		attached("h6", "notes.txt", tkt, "src6", "2026-01-06T00:00:00"),
	}); err != nil {
		t.Fatal(err)
	}
	got, _ = ix.AttachmentsFor(tkt)
	if len(got) != 2 {
		t.Fatalf("re-attach did not restore the file: %+v", got)
	}

	// Every card is still counted — the census matches fossil's
	// attachment table, deletions included.
	n, err := ix.CountAttachments()
	if err != nil {
		t.Fatal(err)
	}
	if n != 6 {
		t.Fatalf("counted %d attachment artifacts, want 6", n)
	}
}

// Folds are idempotent: redelivery must not double-count.
func TestAttachmentFoldIsIdempotent(t *testing.T) {
	ix := openIndex(t)
	batch := []Parsed{attached("h1", "a.txt", "tgt", "src", "2026-01-01T00:00:00")}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	n, _ := ix.CountAttachments()
	if n != 1 {
		t.Fatalf("redelivery produced %d rows", n)
	}
}

// AttachmentName is the gate on the download route: it must resolve only
// blobs that are LIVE attachments. Anything looser turns the route into
// a read-any-artifact-by-hash endpoint, and a deleted file must stop
// being reachable by URL.
func TestAttachmentNameGatesDownloads(t *testing.T) {
	ix := openIndex(t)
	if err := ix.FoldNow([]Parsed{
		attached("h1", "spec.pdf", "tkt", "livesrc", "2026-01-01T00:00:00"),
		attached("h2", "gone.txt", "tkt", "deadsrc", "2026-01-02T00:00:00"),
		attached("h3", "gone.txt", "tkt", "", "2026-01-03T00:00:00"), // deletion
	}); err != nil {
		t.Fatal(err)
	}
	if name, ok, err := ix.AttachmentName("livesrc"); err != nil || !ok || name != "spec.pdf" {
		t.Fatalf("live attachment: name=%q ok=%v err=%v", name, ok, err)
	}
	// Deleted: the content artifact still exists in the store, but the
	// newest card for that filename is a deletion.
	if _, ok, err := ix.AttachmentName("deadsrc"); err != nil || ok {
		t.Fatalf("deleted attachment still downloadable: ok=%v err=%v", ok, err)
	}
	// An artifact that was never attached — a check-in manifest, a file
	// blob — must not be reachable.
	if _, ok, err := ix.AttachmentName("some-unrelated-artifact"); err != nil || ok {
		t.Fatalf("unattached artifact served: ok=%v err=%v", ok, err)
	}
}

// The A card's own artifact carries an N card, and fossil's attachment
// page reports the file's type from it. Folding it is what lets a reader
// be offered an inline image instead of a download.
func TestAttachmentMimetypeIsFolded(t *testing.T) {
	ix := openIndex(t)
	withType := func(hash, name, target, src, date, mime string) Parsed {
		p := attached(hash, name, target, src, date)
		p.Art.Mimetype = mime
		return p
	}
	if err := ix.FoldNow([]Parsed{
		withType("h1", "shot.png", "tkt", "src1", "2026-01-01T00:00:00", "image/png"),
		attached("h2", "notes.txt", "tkt", "src2", "2026-01-02T00:00:00"),
	}); err != nil {
		t.Fatal(err)
	}
	got, err := ix.AttachmentsFor("tkt")
	if err != nil {
		t.Fatal(err)
	}
	byName := map[string]Attachment{}
	for _, a := range got {
		byName[a.Name] = a
	}
	if byName["shot.png"].Mimetype != "image/png" {
		t.Fatalf("FAIL: mimetype not folded: %+v", byName["shot.png"])
	}
	if byName["notes.txt"].Mimetype != "" {
		t.Fatalf("FAIL: absent N card invented a type: %+v", byName["notes.txt"])
	}
	t.Log("ok: N card folded onto the attachment row")
}

// The head memo is a cache over immutable bytes: a miss answers "not
// known" rather than failing, a write is readable, and Reset drops it
// with every other derived table.
func TestAttachmentHeadMemo(t *testing.T) {
	ix := openIndex(t)
	if _, ok, err := ix.AttachmentHeadFor("blob1"); err != nil || ok {
		t.Fatalf("FAIL: a cold memo answered ok=%v err=%v", ok, err)
	}
	want := AttachmentHead{Size: 4096, Kind: "text", Lines: 8,
		Truncated: true, Text: "one\ntwo"}
	ix.RememberAttachmentHead("blob1", want)
	got, ok, err := ix.AttachmentHeadFor("blob1")
	if err != nil || !ok {
		t.Fatalf("FAIL: ok=%v err=%v", ok, err)
	}
	if got != want {
		t.Fatalf("FAIL: memo = %+v, want %+v", got, want)
	}
	// A wider prefix or a new signature must be able to overwrite an
	// older verdict without a migration.
	want.Truncated, want.Lines = false, 12
	ix.RememberAttachmentHead("blob1", want)
	if got, _, _ := ix.AttachmentHeadFor("blob1"); got != want {
		t.Fatalf("FAIL: the verdict did not overwrite: %+v", got)
	}
	if err := ix.Reset(); err != nil {
		t.Fatal(err)
	}
	if _, ok, _ := ix.AttachmentHeadFor("blob1"); ok {
		t.Fatal("FAIL: the memo survived a reset — it is derived, and reset drops derived tables")
	}
	t.Log("ok: the head memo caches a verdict, overwrites it, and is dropped on reset")
}

// A stub folds with its fields beside the row (RFC 0016 Phase 3): the
// list says external with the stub's hash and size, a real file says
// neither, and the row is reachable by its content hash and by its
// own hash for the download redirect and the hold table.
func TestStubFieldsFoldBesideTheRow(t *testing.T) {
	ix := openIndex(t)
	tkt := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
	sum := strings.Repeat("cd", 32)
	withStub := attached("s1", "clip.mp4", tkt, "stubblob", "2026-01-01T00:00:00")
	withStub.Stub = &stub.Stub{SHA256: sum, Size: 18349022, Type: "video/mp4", Name: "clip.mp4"}
	if err := ix.FoldNow([]Parsed{withStub, attached("f1", "spec.pdf", tkt, "fileblob", "2026-01-02T00:00:00")}); err != nil {
		t.Fatal(err)
	}
	got, err := ix.AttachmentsFor(tkt)
	if err != nil || len(got) != 2 {
		t.Fatalf("FAIL: %v %v", got, err)
	}
	by := map[string]Attachment{}
	for _, a := range got {
		by[a.Name] = a
	}
	if s := by["clip.mp4"]; !s.External || s.SHA256 != sum || s.Size != 18349022 {
		t.Errorf("FAIL: stub row = %+v", s)
	}
	if f := by["spec.pdf"]; f.External || f.SHA256 != "" || f.Size != -1 {
		t.Errorf("FAIL: file row = %+v", f)
	}
	if row, ok, err := ix.AttachmentBySrc("stubblob"); err != nil || !ok || !row.External || row.Hash != "s1" {
		t.Errorf("FAIL: by src = %+v %v %v", row, ok, err)
	}
	if row, ok, err := ix.AttachmentByHash("s1"); err != nil || !ok || row.Src != "stubblob" {
		t.Errorf("FAIL: by hash = %+v %v %v", row, ok, err)
	}
	if err := ix.FoldNow([]Parsed{attached("s2", "clip.mp4", tkt, "", "2026-01-03T00:00:00")}); err != nil {
		t.Fatal(err)
	}
	if _, ok, _ := ix.AttachmentBySrc("stubblob"); ok {
		t.Errorf("FAIL: a removed stub still answers by src")
	}
	t.Log("ok: stub fields ride the row; removal takes the row out of reach")
}