Skip to content
quarry Artifact [462f2de0b0]
Login

Artifact [462f2de0b0]

Artifact 462f2de0b0382d3773644c55a5972103b50c2f2c0939aaf84056d750fea36a65:


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)
	}
}