Skip to content
quarry Artifact [8c878f7976]
Login

Artifact [8c878f7976]

Artifact 8c878f79767ad64c75f0f4d00e214c66cf45fa83b6ddbb7be29b12c45c99cebe:


package api

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

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

// caretDeps is a forge with one repository, demo, whose ticket 1 is tid.
func caretDeps(t *testing.T, room ChatPlane) (Deps, string) {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "m.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	tid := strings.Repeat("ab", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: "t1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "a ticket"}}}},
	}); err != nil {
		t.Fatal(err)
	}
	db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	if err := db.TicketNumbersAssign("demo", []string{tid}); err != nil {
		t.Fatal(err)
	}
	tl := func(_ string, n int) (string, bool) { return tid, n == 1 }
	prev := ticketByNumber.Swap(&tl)
	t.Cleanup(func() { ticketByNumber.Store(prev) })
	d := Deps{Numbers: db, Origin: "https://forge.example", Chat: room, CanRead: func(context.Context, string) bool { return true },
		CapsFor: func(_, _ string) CapSet { return fakeCaps(anyCapLetters) }}
	d.Repos = NewRepos(map[string]*Repo{"demo": {Code: "demo", Index: ix}})
	return d, tid
}

// kindsOf is each minted reference's data-ref-kind, in order.
func kindsOf(html string) []string {
	var out []string
	for _, part := range strings.Split(html, `data-ref-kind="`)[1:] {
		out = append(out, part[:strings.IndexByte(part, '"')])
	}
	return out
}

// #N is a ticket and ^N a message in a chat line posted today (#309);
// a line posted before ^N keeps reading #N as the message it meant,
// edited or not. "ticket #N" is the ticket in both.
func TestChatLinesReadNumbersByTheirGrammar(t *testing.T) {
	d, _ := caretDeps(t, &recordingChat{})
	repo, _ := d.Repos.Get("demo")
	cases := []struct {
		name  string
		m     chat.Message
		kinds []string
		refs  []string
	}{
		{"new", chat.Message{Seq: 9, Kind: chat.KindMsg, Text: "#1 and ^5 and ticket #1", Grammar: chat.GrammarCaret},
			[]string{"ticket", "chat", "ticket"}, []string{"ticket/1", "#5"}},
		{"old", chat.Message{Seq: 9, Kind: chat.KindMsg, Text: "#1 and ^5 and ticket #1"},
			[]string{"chat", "chat", "ticket"}, []string{"#1", "#5", "ticket/1"}},
		{"old edited", chat.Message{Seq: 9, Kind: chat.KindMsg, Text: "#1 confirmed", Edited: true},
			[]string{"chat"}, []string{"#1"}},
		{"code", chat.Message{Seq: 9, Kind: chat.KindMsg, Text: "`^5` and `#1`", Grammar: chat.GrammarCaret},
			nil, nil},
	}
	for _, c := range cases {
		out := d.chatRenderOne(context.Background(), repo, "demo", c.m)
		if got := kindsOf(out.HTML); strings.Join(got, ",") != strings.Join(c.kinds, ",") {
			t.Errorf("FAIL: %s: kinds %v, want %v\n%s", c.name, got, c.kinds, out.HTML)
		}
		if c.refs != nil && strings.Join(out.Refs, ",") != strings.Join(c.refs, ",") {
			t.Errorf("FAIL: %s: refs %v, want %v", c.name, out.Refs, c.refs)
		}
	}
	t.Log("ok: new lines read #N as a ticket and ^N as a message; old lines keep #N a message")
}

// A draft previews as it will post: in today's grammar.
func TestChatPreviewReadsTodaysGrammar(t *testing.T) {
	d, _ := caretDeps(t, &recordingChat{})
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "chat.preview", chatPreviewIn{Repo: "demo", Text: "#1 and ^5"})
	if err != nil {
		t.Fatal(err)
	}
	if k := kindsOf(got.(chatPreviewOut).HTML); strings.Join(k, ",") != "ticket,chat" {
		t.Fatalf("FAIL: preview kinds %v", k)
	}
	t.Log("ok: the preview reads #1 as the ticket and ^5 as the message")
}

// recordedRefs keeps every backlink row a post wrote.
type recordedRefs struct {
	pruningRefs
	targets []string
}

func (r *recordedRefs) RecordChatRef(_, target string, _ uint64, _ string) error {
	r.targets = append(r.targets, target)
	return nil
}

// A posted #1 puts a row on ticket 1's page; a posted ^5 is a message,
// which has no page to put a row on.
func TestChatPostBacklinksReadTodaysGrammar(t *testing.T) {
	d, tid := caretDeps(t, &recordingChat{})
	store := &recordedRefs{}
	d.ChatRefs = store
	repo, _ := d.Repos.Get("demo")
	d.recordChatRefs(context.Background(), repo, "demo", "#1 and ^5", 9)
	if len(store.targets) != 1 || store.targets[0] != tid {
		t.Fatalf("FAIL: rows %v, want [%s]", store.targets, tid)
	}
	t.Log("ok: #1 is backlinked to the ticket, ^5 to nothing")
}

// A chat line's mail reads it as the site does: #1 the ticket, ^5 the
// message.
func TestChatMailReadsTodaysGrammar(t *testing.T) {
	d, tid := caretDeps(t, &recordingChat{})
	e := d.withMailHTML(NotifyEvent{Workflow: "quarry-mention", To: "eve", Payload: map[string]any{
		"repo": "demo", "from": "dan", "text": "@eve #1 and ^5", "surface": "chat",
		"link": "https://forge.example/r/demo/chat/9"}}, time.Now())
	h, _ := e.Payload["html"].(string)
	for _, want := range []string{`href="https://forge.example/r/demo/ticket/` + tid, `href="https://forge.example/r/demo/chat/5"`, `/icons/chat.png`} {
		if !strings.Contains(h, want) {
			t.Errorf("FAIL: mail lacks %s\n%s", want, h)
		}
	}
	if strings.Contains(h, "/chat/1\"") {
		t.Errorf("FAIL: mail read #1 as a message\n%s", h)
	}
	t.Log("ok: the mail links #1 to the ticket and ^5 to the message")
}