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")
}
// A team room's live preview reads a draft as it will post: in today's
// grammar, against the repository it is said in.
func TestTeamRoomPreviewReadsTodaysGrammar(t *testing.T) {
d, _ := caretDeps(t, &fakeChat{})
db := d.Numbers.(*oltp.DB)
if err := db.AddAccount("owen", "a-long-enough-password", false); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("owen", "demo", "a"); err != nil {
t.Fatal(err)
}
d.Teams, d.Accounts = db, acctAdapter{db}
d.CapsFor = func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) }
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
if _, err := r.Invoke(asUser("owen"), "team.create", teamCreateIn{Name: "grove"}); err != nil {
t.Fatal(err)
}
if _, err := r.Invoke(asUser("owen"), "team.repo.add", teamRepoIn{Name: "grove", Repo: "demo"}); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(asUser("owen"), "team.chat.render", teamRenderIn{Name: "grove", Repo: "demo", Text: "#1 and ^5"})
if err != nil {
t.Fatal(err)
}
if k := kindsOf(got.(teamRenderOut).HTML); strings.Join(k, ",") != "ticket,chat" {
t.Fatalf("FAIL: team preview kinds %v\n%s", k, got.(teamRenderOut).HTML)
}
t.Log("ok: the room's preview reads #1 as the ticket and ^5 as the message")
}
// A line bridged in from fossil chat was written in fossil's grammar,
// where #N is fossil's own message id — not a quarry sequence (the
// bridge keeps a msgid→seq map) and not a ticket. Linking it either
// way points somewhere its author never meant, so it stays text; ^N
// is quarry's own spelling and still links.
func TestAFossilLineLeavesItsNumbersAsText(t *testing.T) {
d, _ := caretDeps(t, &recordingChat{})
repo, _ := d.Repos.Get("demo")
for _, g := range []int{0, chat.GrammarCaret} {
out := d.chatRenderOne(context.Background(), repo, "demo", chat.Message{
Seq: 9, Kind: chat.KindMsg, Origin: "fossil", Text: "#1 and ^5 and ticket #1", Grammar: g})
if k := kindsOf(out.HTML); strings.Join(k, ",") != "chat,ticket" {
t.Errorf("FAIL: grammar %d: fossil line kinds %v\n%s", g, k, out.HTML)
}
if strings.Join(out.Refs, ",") != "#5,ticket/1" {
t.Errorf("FAIL: grammar %d: fossil line refs %v", g, out.Refs)
}
}
t.Log("ok: a fossil line's #1 is text, ^5 a message, ticket #1 the ticket")
}