package api
import (
"context"
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/ops"
)
// promoteDeps is a forge with a chat plane and one repository whose
// channel is "codeXYZ" — enough for every half of chat.promote that
// does not touch the store. The write half needs a real zit and is
// proved in internal/harness.
func promoteDeps(t *testing.T) (Deps, *fakeChat) {
t.Helper()
fc := &fakeChat{}
repos := testRepos(wikiIndex(t))
mustRepo(t, repos, "test").Code = "codeXYZ"
return Deps{Version: "v", Repos: repos, Chat: fc}, fc
}
func post(t *testing.T, fc *fakeChat, user, text string) uint64 {
t.Helper()
seq, err := fc.Post("codeXYZ", chat.Message{Repo: "test", User: user, Text: text})
if err != nil {
t.Fatal(err)
}
return seq
}
// Every way a range can be wrong is ErrInvalid, and says which way.
func TestPromoteRangeRefusals(t *testing.T) {
d, fc := promoteDeps(t)
first := post(t, fc, "dan", "one")
last := post(t, fc, "eve", "two")
cases := []struct {
name string
from, to uint64
}{
{"reversed", last, first},
{"span too wide", first, first + promoteMaxSpan},
{"aged out", 0, last}, // 0 is below any window's first seq
{"nothing in it", last + 5, last + 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := d.promoteRange("codeXYZ", c.from, c.to); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
})
}
// An empty channel is refused before anything is scanned.
if _, err := d.promoteRange("nobody-here", 1, 2); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("empty channel: want ErrInvalid, got %v", err)
}
}
// A range holding more messages than one thread may fold is refused
// even though its span is legal: the two bounds are different numbers
// and the message count is the one a person's selection produces.
func TestPromoteRefusesTooManyMessages(t *testing.T) {
d, fc := promoteDeps(t)
var first, last uint64
for i := 0; i <= promoteMaxMessages; i++ {
seq := post(t, fc, "dan", "line")
if first == 0 {
first = seq
}
last = seq
}
_, err := d.promoteRange("codeXYZ", first, last)
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
if !strings.Contains(err.Error(), "201") {
t.Fatalf("refusal does not say how many were selected: %v", err)
}
}
// Tombstoned, robot and blank lines are not conversation: a promote
// folds what a reader can see, which is what Fold already answers.
func TestPromoteRangeFoldsTheChannel(t *testing.T) {
d, fc := promoteDeps(t)
keep := post(t, fc, "dan", "the point")
gone := post(t, fc, "eve", "oops")
if _, err := fc.Post("codeXYZ", chat.Message{Repo: "test", User: "eve",
Kind: chat.KindDel, Ref: gone}); err != nil {
t.Fatal(err)
}
if _, err := fc.Post("codeXYZ", chat.Message{Repo: "test", User: "robot",
Kind: chat.KindRobot, Origin: "robot", Text: "check-in abc"}); err != nil {
t.Fatal(err)
}
edited := post(t, fc, "dan", "before")
if _, err := fc.Post("codeXYZ", chat.Message{Repo: "test", User: "dan",
Kind: chat.KindEdit, Ref: edited, Text: "after"}); err != nil {
t.Fatal(err)
}
_, last, err := fc.Bounds("codeXYZ")
if err != nil {
t.Fatal(err)
}
got, err := d.promoteRange("codeXYZ", keep, last)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("folded run = %d messages, want 2: %+v", len(got), got)
}
if got[0].Seq != keep || got[1].Text != "after" {
t.Fatalf("fold did not survive promotion: %+v", got)
}
}
// The body is the wire format a thread is opened with: a back-link
// line, then one entry per message, references carried through as the
// tokens they were typed as.
func TestPromoteBodyCarriesReferencesAndCode(t *testing.T) {
msgs := []chat.Message{
{Seq: 4, User: "dan", Text: "see [a91f3c2e] and #3, cc @eve", At: "2026-08-29T14:03:09.1Z"},
{Seq: 6, User: "eve", Text: "try:\n\n```go\nfmt.Println(1)\n```", At: "2026-08-29T14:05:00Z"},
}
body := promoteBody("sqlite", 4, 6, msgs)
for _, want := range []string{
"[chat](/r/sqlite/chat)",
"[#4](/r/sqlite/chat/4)",
"[#6](/r/sqlite/chat/6)",
"**dan** · 14:03 — see [a91f3c2e] and #3, cc @eve",
"**eve** · 14:05 —",
"```go\nfmt.Println(1)\n```",
} {
if !strings.Contains(body, want) {
t.Fatalf("body is missing %q:\n%s", want, body)
}
}
// A multi-line message's fence must start at the beginning of a
// line, or it is not a fence any more.
if strings.Contains(body, "— ```") {
t.Fatalf("a code fence was folded onto its byline:\n%s", body)
}
}
// An untitled promote names itself after the first message.
func TestPromoteTitleDefaultsToTheFirstLine(t *testing.T) {
for text, want := range map[string]string{
"## Why the lease is per store\nbecause": "Why the lease is per store",
" plain words ": "plain words",
"": "chat",
"> quoted opener": "quoted opener",
} {
if got := promoteTitle(text); got != want {
t.Fatalf("promoteTitle(%q) = %q, want %q", text, got, want)
}
}
}
// The announcement is a robot line on the robot's own path: robot
// kind, robot origin, robot author — the three things Unread and the
// renderer both key on.
func TestPromoteRobotLineTakesTheRobotPath(t *testing.T) {
d, fc := promoteDeps(t)
repo, _ := d.Repos.Get("test")
seq := d.postRobotLine(context.Background(), repo, "test", "promoted #4–#6 [deadbeef]")
if seq == 0 {
t.Fatal("robot line got no sequence")
}
line := fc.msgs[len(fc.msgs)-1]
if line.Kind != chat.KindRobot || line.Origin != "robot" || line.User != "robot" {
t.Fatalf("promote's note is not a robot line: %+v", line)
}
if line.Text != "promoted #4–#6 [deadbeef]" {
t.Fatalf("robot line text = %q", line.Text)
}
}
// chat.promote is registered with both letters and the route the RFC
// names; the range refusal reaches a caller through the op, not only
// through the helper.
func TestPromoteOpIsRegistered(t *testing.T) {
d, fc := promoteDeps(t)
last := post(t, fc, "dan", "hello")
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
op, ok := r.Lookup("chat.promote")
if !ok {
t.Fatal("chat.promote is not registered")
}
if op.Caps != "C3" || op.Method != "POST" ||
op.Pattern != "/api/v1/r/{repo}/chat/promote" || op.Auth != ops.Token {
t.Fatalf("chat.promote surface = caps %q %s %s auth %v",
op.Caps, op.Method, op.Pattern, op.Auth)
}
_, err := r.Invoke(context.Background(), "chat.promote",
chatPromoteIn{Repo: "test", From: last, To: last - 1})
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("reversed range through the op: want ErrInvalid, got %v", err)
}
}
// capSet is a CapSet over a literal letter string.
type capSet string
func (c capSet) HasAny(letters string) bool {
return strings.ContainsAny(string(c), letters)
}
// The router's gate is "any of C or 3"; the op's is "both". Holding
// one letter is not permission to perform the other half.
func TestPromoteNeedsBothLetters(t *testing.T) {
d, fc := promoteDeps(t)
last := post(t, fc, "dan", "hello")
held := "C"
d.CapsFor = func(_, _ string) CapSet { return capSet(held) }
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
for _, only := range []string{"C", "3"} {
held = only
_, err := r.Invoke(context.Background(), "chat.promote",
chatPromoteIn{Repo: "test", From: last, To: last})
if !errors.Is(err, ops.ErrForbidden) {
t.Fatalf("with only %q: want ErrForbidden, got %v", only, err)
}
}
// With both, the capability check is past and the op reaches the
// range it was actually given.
held = "C3"
_, err := r.Invoke(context.Background(), "chat.promote",
chatPromoteIn{Repo: "test", From: last, To: last - 1})
if !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("with both letters: want the range refusal, got %v", err)
}
}