Skip to content
quarry Artifact [9ae7fe9955]
Login

Artifact [9ae7fe9955]

Artifact 9ae7fe9955eb013ef08593c7bd59849011fa1049591131fbe6dc646def7f9a66:


package api

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

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

// attachedIndex folds one wiki page, one technote and one forum post,
// each carrying a file, plus a body that references it by name. The
// targets are fossil's: a wiki page is targeted by NAME, a technote by
// its event id, a forum post by its own hash.
func attachedIndex(t *testing.T) *index.Index {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	att := func(hash, name, target, src, mime string) index.Parsed {
		return index.Parsed{Hash: hash, Art: &artifact.Artifact{
			Type: artifact.Attachment, Date: "2026-01-02T00:00:00", User: "dan",
			Mimetype:   mime,
			Attachment: &artifact.Attach{Name: name, Target: target, Source: src},
		}}
	}
	if err := ix.FoldNow([]index.Parsed{
		{Hash: "w1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Design",
			Date: "2026-01-01T00:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("see [the shot](attach:crash.png) and [the ghost](attach:gone.png)\n")}},
		{Hash: "e1", Art: &artifact.Artifact{Type: artifact.Technote,
			EventID: "eeee111122223333444455556666777788889999", Comment: "a note",
			Date: "2026-01-01T00:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("![shot](attach:note.png)\n")}},
		{Hash: "f1", Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "hello",
			Date: "2026-01-01T00:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("look at [this](attach:post.png)\n")}},
		att("a1", "crash.png", "Design", "blobw", "image/png"),
		att("a2", "note.png", "eeee111122223333444455556666777788889999", "blobe", "image/png"),
		att("a3", "post.png", "f1", "blobf", "image/png"),
	}); err != nil {
		t.Fatal(err)
	}
	return ix
}

func attachRegistry(t *testing.T) *ops.Registry {
	t.Helper()
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(attachedIndex(t))}); err != nil {
		t.Fatal(err)
	}
	return r
}

// Every target lists its own files (RFC 0016 §Reading), and the body's
// attach: references resolve against the SAME object — which is the only
// thing that makes a reference by filename unambiguous.
func TestWikiGetCarriesAttachments(t *testing.T) {
	got, err := attachRegistry(t).Invoke(context.Background(), "wiki.get",
		wikiGetIn{Repo: "test", Name: "Design"})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(wikiGetOut)
	if len(out.Attachments) != 1 || out.Attachments[0].Name != "crash.png" {
		t.Fatalf("FAIL: wiki attachments = %+v", out.Attachments)
	}
	if out.Attachments[0].Mimetype != "image/png" {
		t.Fatalf("FAIL: declared type lost: %+v", out.Attachments[0])
	}
	if !strings.Contains(out.HTML, "/r/test/attachment/blobw") {
		t.Fatalf("FAIL: attach: reference did not resolve on the page:\n%s", out.HTML)
	}
	if strings.Contains(out.HTML, "attach:") {
		t.Fatalf("FAIL: an unresolved reference stayed a link:\n%s", out.HTML)
	}
	t.Log("ok: wiki.get carries its files and resolves attach: against the page name")
}

func TestTechnoteGetCarriesAttachments(t *testing.T) {
	got, err := attachRegistry(t).Invoke(context.Background(), "technote.get",
		technoteGetIn{Repo: "test", Event: "eeee1111"})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(technoteGetOut)
	if len(out.Attachments) != 1 || out.Attachments[0].Name != "note.png" {
		t.Fatalf("FAIL: technote attachments = %+v", out.Attachments)
	}
	if !strings.Contains(out.HTML, "/r/test/attachment/blobe/view") {
		t.Fatalf("FAIL: an inline image did not take the view route:\n%s", out.HTML)
	}
	t.Log("ok: technote.get carries its files and images take the inline route")
}

func TestForumGetCarriesPerPostAttachments(t *testing.T) {
	got, err := attachRegistry(t).Invoke(context.Background(), "forum.get",
		forumGetIn{Repo: "test", Root: "f1"})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(forumGetOut)
	if len(out.Posts) != 1 {
		t.Fatalf("FAIL: posts = %+v", out.Posts)
	}
	if len(out.Posts[0].Attachments) != 1 || out.Posts[0].Attachments[0].Name != "post.png" {
		t.Fatalf("FAIL: post attachments = %+v", out.Posts[0].Attachments)
	}
	if !strings.Contains(out.Posts[0].HTML, "/r/test/attachment/blobf") {
		t.Fatalf("FAIL: a post body did not resolve its own file:\n%s", out.Posts[0].HTML)
	}
	t.Log("ok: forum.get carries attachments per post, keyed on the post hash")
}

func TestAttachmentListOp(t *testing.T) {
	r := attachRegistry(t)
	got, err := r.Invoke(context.Background(), "attachment.list",
		attachmentListIn{Repo: "test", Target: "Design"})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(attachmentListOut)
	if len(out.Attachments) != 1 || out.Attachments[0].Src != "blobw" {
		t.Fatalf("FAIL: attachment.list = %+v", out.Attachments)
	}
	// A target with nothing on it is an empty list, not an error: an
	// object that carries no files is the ordinary case.
	got, err = r.Invoke(context.Background(), "attachment.list",
		attachmentListIn{Repo: "test", Target: "Nothing"})
	if err != nil {
		t.Fatal(err)
	}
	if len(got.(attachmentListOut).Attachments) != 0 {
		t.Fatalf("FAIL: a bare target answered %+v", got)
	}
	t.Log("ok: attachment.list answers one target's live files")
}