package api
import (
"context"
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/attach"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// zitSync is the Repo.Sync a test wires: walk the store's frames from
// where the last walk stopped and fold what is new, which is what the
// ingest does in production. The write ops call it before they
// answer, so an object written a moment ago is in the index by the
// time the composer's attachments are committed against it.
func zitSync(z *zitcli.Client, ix *index.Index) func() (int, error) {
var cur zitcli.Cursor
return func() (int, error) {
var parsed []index.Parsed
next, err := z.FramesEach(cur, func(a zitcli.Artifact) error {
art, perr := artifact.Parse(a.Bytes)
if perr != nil || art == nil {
return nil
}
p := index.Parsed{Hash: a.Hash, Art: art}
if art.Type == artifact.Attachment && art.Attachment != nil {
if raw, rerr := z.ArtifactRaw(context.Background(), art.Attachment.Source, 4096); rerr == nil {
if stub, serr := attach.Parse(raw); serr == nil {
p.Stub = &stub
}
}
}
parsed = append(parsed, p)
return nil
})
if err != nil {
return 0, err
}
cur = next
return len(parsed), ix.FoldNow(parsed)
}
}
// deferredFixture is a real store with one technote and one forum
// thread, a bucket, and the ops; the repo syncs through zitSync.
type deferredFixture struct {
r *ops.Registry
store *memAttachments
objects *objectS3
ix *index.Index
sync func() (int, error)
note string // the technote's event id
root string // the thread's hash
}
func newDeferredFixture(t *testing.T) deferredFixture {
t.Helper()
ix, z, event := attachStore(t)
srv, objects := newObjectS3(t)
buckets := newMemBuckets()
buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: 1 << 20}
store := newMemAttachments(buckets)
sync := zitSync(z, ix)
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z, Sync: sync}}),
Buckets: buckets, Attachments: store}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
out, err := r.Invoke(as("dan", "3"), "forum.post", forumPostIn{Repo: "test", Title: "hello", Body: "first\n"})
if err != nil {
t.Fatalf("forum.post: %v", err)
}
root := out.(forumPostOut).Hash
if root == "" {
t.Fatal("forum.post answered no hash")
}
return deferredFixture{r: r, store: store, objects: objects, ix: ix, sync: sync, note: event, root: root}
}
func (f deferredFixture) begin(ctx context.Context, family string) (attachmentBeginOut, error) {
out, err := f.r.Invoke(ctx, "attachment.begin", attachmentBeginIn{Repo: "test", Family: family, Name: "shot.png", Size: 500, Type: "image/png", SHA256: sha})
if err != nil {
return attachmentBeginOut{}, err
}
return out.(attachmentBeginOut), nil
}
func (f deferredFixture) commit(ctx context.Context, ticket, target string) (attachmentCommitOut, error) {
out, err := f.r.Invoke(ctx, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket, Target: target})
if err != nil {
return attachmentCommitOut{}, err
}
return out.(attachmentCommitOut), nil
}
// A deferred ticket: begun with a family and no target, gated on that
// family's letter, and committed only with a target of that family.
func TestDeferredTicketCommitsWithATarget(t *testing.T) {
f := newDeferredFixture(t)
if _, err := f.begin(as("lou", "B"), "technote"); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: a technote family deferred: %v", err)
}
if _, err := f.begin(as("lou", "B"), ""); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: no target and no family: %v", err)
}
if _, err := f.begin(as("lou", "b"), "forum"); !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: the ticket letter began a forum upload: %v", err)
}
lou := as("lou", "B")
b, err := f.begin(lou, "forum")
if err != nil {
t.Fatalf("FAIL: deferred begin: %v", err)
}
if tk := f.store.tickets[b.Ticket]; tk.Target != "" || tk.Family != "forum" || tk.Who != "lou" {
t.Errorf("FAIL: ticket = %+v", tk)
}
f.objects.objects[attach.Key(sha)] = 500
if _, err := f.commit(lou, b.Ticket, ""); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: commit without a target: %v", err)
}
if _, err := f.commit(lou, b.Ticket, "nothing-here"); !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: commit to an unknown target: %v", err)
}
// Begun for the forum with B; a technote wants b, and the ticket
// must not cross families whatever letters the caller holds now.
if _, err := f.commit(as("lou", "bB"), b.Ticket, f.note[:16]); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: a forum ticket landed on a technote: %v", err)
}
if _, ok, _ := f.store.UploadTicket(b.Ticket); !ok {
t.Fatal("FAIL: the ticket was spent by a refusal")
}
c, err := f.commit(lou, b.Ticket, f.root[:16])
if err != nil {
t.Fatalf("FAIL: commit with the target: %v", err)
}
if len(c.Artifact) != 64 || c.Name != "shot.png" || !c.Held {
t.Errorf("FAIL: commit = %+v", c)
}
if _, ok, _ := f.store.UploadTicket(b.Ticket); ok {
t.Errorf("FAIL: the ticket was not spent")
}
t.Log("ok: a deferred ticket is gated on the family's letter and needs a target of its family")
}
// forum.reply with attach: the reply is written, the tickets are
// committed against its hash, and a bad ticket is reported without
// undoing the reply.
func TestForumReplyAttachesWhatWasPasted(t *testing.T) {
f := newDeferredFixture(t)
lou := as("lou", "3B5")
b, err := f.begin(lou, "forum")
if err != nil {
t.Fatal(err)
}
f.objects.objects[attach.Key(sha)] = 500
if _, err := f.r.Invoke(lou, "forum.reply", forumReplyIn{Repo: "test", Root: f.root[:16], Body: "x\n",
Attach: make([]string, maxComposerAttachments+1)}); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: more tickets than the op takes: %v", err)
}
got, err := f.r.Invoke(lou, "forum.reply", forumReplyIn{Repo: "test", Root: f.root[:16], Body: "\n", Attach: []string{b.Ticket, "nope"}})
if err != nil {
t.Fatalf("FAIL: reply: %v", err)
}
rep := got.(forumReplyOut)
if rep.Hash == "" || rep.Staged {
t.Fatalf("FAIL: reply = %+v", rep)
}
if len(rep.AttachErrors) != 1 || !strings.HasPrefix(rep.AttachErrors[0], "nope: ") {
t.Errorf("FAIL: attachErrors = %v", rep.AttachErrors)
}
if _, ok, _ := f.store.UploadTicket(b.Ticket); ok {
t.Errorf("FAIL: the good ticket was not spent")
}
// The A card names the reply: after the next sync the row is on it.
if _, err := f.sync(); err != nil {
t.Fatal(err)
}
rows, err := f.ix.AttachmentsFor(rep.Hash)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Name != "shot.png" || !rows[0].External {
t.Errorf("FAIL: attachments on the reply = %+v", rows)
}
t.Log("ok: forum.reply commits the pasted files against the reply and reports the bad ticket")
}
// ticket.set and wiki.save take the same list: the files pasted into
// a comment box or the wiki editor are committed against the object
// after the change is written, and files alone on a ticket are
// attached without a J card.
func TestTicketSetAndWikiSaveAttach(t *testing.T) {
f := newDeferredFixture(t)
dan := as("dan", "bcwkfq")
f.objects.objects[attach.Key(sha)] = 500
out, err := f.r.Invoke(dan, "wiki.save", wikiSaveIn{Repo: "test", Name: "Design", Body: "page\n"})
if err != nil {
t.Fatal(err)
}
if out.(wikiSaveOut).AttachErrors != nil {
t.Errorf("FAIL: a save with no files reported %v", out.(wikiSaveOut).AttachErrors)
}
b, err := f.begin(dan, "wiki")
if err != nil {
t.Fatal(err)
}
out, err = f.r.Invoke(dan, "wiki.save", wikiSaveIn{Repo: "test", Name: "Design", Body: "\n", Attach: []string{b.Ticket}})
if err != nil {
t.Fatalf("FAIL: wiki.save with a file: %v", err)
}
if ws := out.(wikiSaveOut); ws.Hash == "" || ws.AttachErrors != nil {
t.Errorf("FAIL: wiki.save = %+v", ws)
}
if _, err := f.sync(); err != nil {
t.Fatal(err)
}
if rows, _ := f.ix.AttachmentsFor("Design"); len(rows) != 1 || rows[0].Name != "shot.png" {
t.Errorf("FAIL: attachments on the page = %+v", rows)
}
out, err = f.r.Invoke(dan, "ticket.new", ticketNewIn{Repo: "test", Title: "broken"})
if err != nil {
t.Fatal(err)
}
id := out.(ticketNewOut).ID
b, err = f.begin(dan, "ticket")
if err != nil {
t.Fatal(err)
}
// A wiki-family ticket does not land on a ticket, and the change
// itself is still written.
wrong, err := f.begin(dan, "wiki")
if err != nil {
t.Fatal(err)
}
out, err = f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10], Comment: "see the shot", Attach: []string{b.Ticket, wrong.Ticket}})
if err != nil {
t.Fatalf("FAIL: ticket.set with files: %v", err)
}
if ts := out.(ticketSetOut); ts.Hash == "" || len(ts.AttachErrors) != 1 || !strings.HasPrefix(ts.AttachErrors[0], wrong.Ticket+": ") {
t.Errorf("FAIL: ticket.set = %+v", ts)
}
if _, err := f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10]}); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: nothing to set and nothing to attach: %v", err)
}
b2, err := f.begin(dan, "ticket")
if err != nil {
t.Fatal(err)
}
out, err = f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10], Attach: []string{b2.Ticket}})
if err != nil {
t.Fatalf("FAIL: files alone: %v", err)
}
if ts := out.(ticketSetOut); ts.Hash != "" || ts.AttachErrors != nil {
t.Errorf("FAIL: files alone = %+v", ts)
}
if _, err := f.sync(); err != nil {
t.Fatal(err)
}
// Two A cards with one name on one target: fossil's rule is that
// the later replaces the earlier, so the ticket shows one file.
if rows, _ := f.ix.AttachmentsFor(id); len(rows) != 1 || rows[0].Name != "shot.png" {
t.Errorf("FAIL: attachments on the ticket = %+v", rows)
}
t.Log("ok: ticket.set and wiki.save commit the pasted files against their object")
}