package index
import (
"path/filepath"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
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 attach(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{
attach("h1", "spec.pdf", tkt, "src1", "2026-01-01T00:00:00"),
attach("h2", "spec.pdf", tkt, "src2", "2026-01-02T00:00:00"), // replaced
attach("h3", "notes.txt", tkt, "src3", "2026-01-03T00:00:00"),
attach("h4", "notes.txt", tkt, "", "2026-01-04T00:00:00"), // deleted
attach("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{
attach("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{attach("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{
attach("h1", "spec.pdf", "tkt", "livesrc", "2026-01-01T00:00:00"),
attach("h2", "gone.txt", "tkt", "deadsrc", "2026-01-02T00:00:00"),
attach("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 := attach(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"),
attach("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"}
if err := ix.PutAttachmentHead("blob1", want); err != nil {
t.Fatal(err)
}
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
if err := ix.PutAttachmentHead("blob1", want); err != nil {
t.Fatal(err)
}
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")
}