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