package mcpsrv_test
import (
"fmt"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv"
)
// ready_work: the one tool on this surface that answers about a set of
// databases rather than about one.
//
// What is under test here is not the ready rule — that is beads' and is tested
// there, and the /ready page reads it through the very same function — but the
// two things only this layer can get wrong: which databases a caller is shown,
// and that the two arms of the tool agree about one of them.
//
// The fixture set below is its own rather than the shared one, because every
// property here is a property of a *set*: several owners, one database of each
// visibility, one that is not a tracker at all and one whose store will not
// open.
// --- the fixture -------------------------------------------------------------
const (
daveID = 4 // owns the private tracker bob is granted on
erinID = 5 // owns the non-beads database and the broken one
frankID = 6 // owns the unlisted tracker
)
// The two markers a leak would carry. They are strings no visible database
// holds, so "nothing about a database the caller may not list reached the
// answer" is a question asked of the serialised payload rather than of a struct.
const (
privateMarker = "SECRETREADY"
unlistedMarker = "UNLISTEDREADY"
)
// readyIssues builds an issues table from (id, title, status, priority,
// assignee, is_blocked) tuples, over the same column set the rest of the beads
// suite uses.
func readyIssues(rows [][6]string) fakeTable {
out := make([][]string, 0, len(rows))
for _, r := range rows {
out = append(out, row(issueColumns, map[string]string{
"id": r[0], "title": r[1], "status": r[2], "priority": r[3],
"assignee": r[4], "is_blocked": r[5], "issue_type": "task",
"created_at": "2026-07-01 10:00:00",
}))
}
return beadsTable("issues", issueColumns, out)
}
// readyTracker is one beads database at head: a "main" branch, one commit, the
// issues given and the dependency edges between them.
func readyTracker(head string, issues fakeTable, deps [][]string) *fakeSession {
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: head}},
commits: []browse.CommitInfo{{
Hash: head, Author: "alice", Date: headTime, Message: "bd: create (auto-commit)",
}},
tables: []fakeTable{
issues,
beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, deps),
},
}
}
// readyFixture is one database of the ready fixture set, with its owner: unlike
// the shared fixtures, these belong to several people, which is what makes the
// listing rule say anything at all.
type readyFixture struct {
owner string
ownerID int
name string
vis core.Visibility
acl map[int]core.AccessMode
session *fakeSession // nil is a store that will not open
}
func (f readyFixture) slug() string { return f.owner + "/" + f.name }
// alphaStore is the busiest tracker: two of its five issues are ready.
//
// a-2 is the case worth naming — it depends on a-5, which is closed, so the
// dependency does not block and the card still carries the count.
func alphaStore() *fakeSession {
issues := readyIssues([][6]string{
{"a-1", "Ready, top priority", "open", "0", "alice", ""},
{"a-2", "Ready, lower", "open", "2", "bob", ""},
{"a-3", "Under way", "in_progress", "0", "alice", ""},
{"a-4", "Waiting on something", "open", "0", "bob", "1"},
{"a-5", "Finished", "closed", "1", "bob", ""},
})
// One long text, so "a listing carries no bodies" is asked of this tool too.
issues.rows[0][indexOfColumn("description")] = body("a-1")
return readyTracker("h-alpha", issues, [][]string{{"a-2", "a-5", "blocks"}})
}
// indexOfColumn is where a named column sits in issueColumns. A name that is not
// there panics rather than writing into the wrong cell.
func indexOfColumn(name string) int {
for i, c := range issueColumns {
if c == name {
return i
}
}
panic("no issue column named " + name)
}
func readyFixtures() []readyFixture {
return []readyFixture{
{owner: "alice", ownerID: aliceID, name: "alpha", vis: core.VisibilityPublic, session: alphaStore()},
{
owner: "bob", ownerID: bobID, name: "beta", vis: core.VisibilityPublic,
session: readyTracker("h-beta", readyIssues([][6]string{
{"b-1", "The one ready thing", "open", "1", "carol", ""},
{"b-2", "Finished", "closed", "0", "carol", ""},
}), nil),
},
{
// PRIVATE: absent from every listing but its owner's and its grantee's,
// and readable by neither anonymity nor a stranger.
owner: "dave", ownerID: daveID, name: "secrets", vis: core.VisibilityPrivate,
acl: map[int]core.AccessMode{bobID: core.AccessRO},
session: readyTracker("h-secrets", readyIssues([][6]string{
{"s-1", privateMarker + " rotate the escrow key", "open", "0", "dave", ""},
}), nil),
},
{
// UNLISTED: absent from the cross-database arm, which is a listing, and
// answered when named directly, which is an address.
owner: "frank", ownerID: frankID, name: "hidden", vis: core.VisibilityUnlisted,
session: readyTracker("h-hidden", readyIssues([][6]string{
{"u-1", unlistedMarker + " the private draft", "open", "1", "frank", ""},
}), nil),
},
{
// Not a beads tracker at all: skipped silently, never read for rows.
owner: "erin", ownerID: erinID, name: "sensors", vis: core.VisibilityPublic,
session: &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "h-sensors"}},
commits: []browse.CommitInfo{{
Hash: "h-sensors", Author: "erin", Date: headTime, Message: "readings",
}},
tables: []fakeTable{newTable("measurements", 3)},
},
},
{
// A store this daemon cannot open: it costs itself and nothing else.
owner: "erin", ownerID: erinID, name: "broken", vis: core.VisibilityPublic,
session: nil,
},
}
}
// readyFakes builds the metadata store and the opener over a fixture set, in the
// order given — which is the order the listing returns and therefore the order
// the aggregation's ceiling takes its candidates in.
func readyFakes(fx []readyFixture) (*fakeRepos, *fakeOpener) {
repos := &fakeRepos{acl: map[int]map[int]core.AccessMode{}}
opener := &fakeOpener{sessions: map[string]*fakeSession{}}
for i, f := range fx {
id := i + 1
repos.repos = append(repos.repos, &core.Repo{
ID: id,
Name: f.name,
Description: "the " + f.name + " database",
OwnerID: f.ownerID,
OwnerName: f.owner,
Path: storePath(f.owner, f.name),
Visibility: f.vis,
})
for userID, mode := range f.acl {
if repos.acl[id] == nil {
repos.acl[id] = map[int]core.AccessMode{}
}
repos.acl[id][userID] = mode
}
if f.session != nil {
opener.sessions[storePath(f.owner, f.name)] = f.session
}
}
return repos, opener
}
// readyServer is the surface over the ready fixture set, with the fakes returned
// so a test can count what was opened and read.
func readyServer(t *testing.T) (*mcpsrv.Server, *fakeRepos, *fakeOpener) {
t.Helper()
repos, opener := readyFakes(readyFixtures())
return newServer(t, repos, opener), repos, opener
}
// --- the shapes a client decodes ---------------------------------------------
type (
readyCardResult struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Priority string `json:"priority"`
Assignee string `json:"assignee"`
Labels []string `json:"labels"`
BlockedBy int `json:"blocked_by"`
Blocks int `json:"blocks"`
}
readyHeadResult struct {
Hash string `json:"hash"`
Time time.Time `json:"time"`
}
readyDatabaseResult struct {
Owner string `json:"owner"`
Name string `json:"name"`
Ref string `json:"ref"`
Head *readyHeadResult `json:"head"`
Ready []readyCardResult `json:"ready"`
Count int `json:"count"`
}
readyUnreadableResult struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
readyWorkResult struct {
Databases []readyDatabaseResult `json:"databases"`
Total int `json:"total"`
Limit int `json:"limit"`
Truncated bool `json:"truncated"`
Considered int `json:"considered"`
MaxDatabases int `json:"max_databases"`
Capped bool `json:"capped"`
Unreadable []readyUnreadableResult `json:"unreadable"`
}
)
func readyWork(t *testing.T, s *mcp.ClientSession, a map[string]any) readyWorkResult {
t.Helper()
var out readyWorkResult
decode(t, call(t, s, "ready_work", a), &out)
return out
}
// readySlugs is the addresses of the databases in an answer, in the order they
// were answered in.
func readySlugs(res readyWorkResult) []string {
out := make([]string, 0, len(res.Databases))
for _, d := range res.Databases {
out = append(out, d.Owner+"/"+d.Name)
}
return out
}
func readyIDs(db readyDatabaseResult) []string {
out := make([]string, 0, len(db.Ready))
for _, c := range db.Ready {
out = append(out, c.ID)
}
return out
}
func readyDatabaseNamed(t *testing.T, res readyWorkResult, slug string) readyDatabaseResult {
t.Helper()
for _, d := range res.Databases {
if d.Owner+"/"+d.Name == slug {
return d
}
}
t.Fatalf("no database %q in the answer: %v", slug, readySlugs(res))
return readyDatabaseResult{}
}
// --- the cross-database arm ---------------------------------------------------
// The arm the tool exists for: no database named, every tracker the caller may
// see, grouped and ordered as the page groups and orders them.
func TestReadyWorkAnswersAcrossEveryTracker(t *testing.T) {
server, _, _ := readyServer(t)
got := readyWork(t, connect(t, server, nil), nil)
assert.Equal(t, []string{"alice/alpha", "bob/beta"}, readySlugs(got),
"the busier database leads; a tracker with nothing ready is absent rather than empty")
assert.Equal(t, 3, got.Total)
assert.Equal(t, 200, got.Limit, "the default of docs/DESIGN.mcp.md §9.3")
assert.False(t, got.Truncated)
assert.False(t, got.Capped)
assert.Equal(t, beads.ReadyMaxDatabases, got.MaxDatabases,
"the ceiling is beads' and is published so the answer can be read against it")
alpha := readyDatabaseNamed(t, got, "alice/alpha")
assert.Equal(t, "main", alpha.Ref, "each database is read at its own default branch")
require.NotNil(t, alpha.Head, "and says which commit it read")
assert.Equal(t, "h-alpha", alpha.Head.Hash)
assert.True(t, headTime.Equal(alpha.Head.Time), "got %v", alpha.Head.Time)
assert.Equal(t, 2, alpha.Count)
assert.Equal(t, []string{"a-1", "a-2"}, readyIDs(alpha), "priority first, then id")
assert.Equal(t, readyCardResult{
ID: "a-2", Title: "Ready, lower", Type: "task", Priority: "2", Assignee: "bob",
Labels: []string{}, BlockedBy: 1, Blocks: 0,
}, alpha.Ready[1], "a closed blocker is still a dependency, and the issue is still ready")
beta := readyDatabaseNamed(t, got, "bob/beta")
assert.Equal(t, "h-beta", beta.Head.Hash, "each group carries its own database's head")
assert.Equal(t, []string{"b-1"}, readyIDs(beta))
}
// The listing rule, per caller: what a caller may be shown is exactly what the
// cross-database arm shows, and nothing about the rest of the instance reaches
// the payload — not a name, not a title, not an id.
func TestReadyWorkShowsExactlyWhatTheListingRuleAllows(t *testing.T) {
server, _, _ := readyServer(t)
for _, tc := range []struct {
name string
caller *auth.AuthContext
want []string
}{
{"anonymous", nil, []string{"alice/alpha", "bob/beta"}},
{"a stranger", carol(), []string{"alice/alpha", "bob/beta"}},
{"a grantee", bob(), []string{"alice/alpha", "bob/beta", "dave/secrets"}},
{"an owner of one of them", alice(), []string{"alice/alpha", "bob/beta"}},
} {
t.Run(tc.name, func(t *testing.T) {
session := connect(t, server, tc.caller)
assert.Equal(t, tc.want, readySlugs(readyWork(t, session, nil)))
payload := resultJSON(t, call(t, session, "ready_work", nil))
assert.NotContains(t, payload, unlistedMarker,
"an unlisted database of somebody else is absent from a listing")
assert.NotContains(t, payload, "hidden")
assert.NotContains(t, payload, "u-1")
if tc.caller == nil || tc.caller.UserID != bobID {
assert.NotContains(t, payload, privateMarker, "not a title")
assert.NotContains(t, payload, "secrets", "not a name")
assert.NotContains(t, payload, "s-1", "not an id")
assert.NotContains(t, payload, "dave", "not an owner")
}
})
}
}
// The grantee's own answer, so the absences above are a visibility rule and not
// a broken tool: bob is granted on the private tracker and reads it.
func TestReadyWorkAnswersAGranteeAboutAPrivateTracker(t *testing.T) {
server, _, _ := readyServer(t)
got := readyWork(t, connect(t, server, bob()), nil)
secrets := readyDatabaseNamed(t, got, "dave/secrets")
assert.Equal(t, []string{"s-1"}, readyIDs(secrets))
assert.Equal(t, 4, got.Total, "its ready work counts towards the answer")
}
// Enumerating is two rules and not one, and this is the database where they
// differ: db/'s listing query is mode-blind — it lists anything the caller holds
// an ACL *row* on — while core.Allowed reads the mode and falls through to
// visibility for one it does not recognise. A PRIVATE database carrying such a
// row is therefore listed to that caller and must still not be read, which is
// the whole job of the second step.
func TestReadyWorkAsksTheAccessRuleAndNotOnlyTheListing(t *testing.T) {
fixture := func(mode core.AccessMode) []readyFixture {
return []readyFixture{{
owner: "dave", ownerID: daveID, name: "quarantine", vis: core.VisibilityPrivate,
acl: map[int]core.AccessMode{carolID: mode},
session: readyTracker("h-quarantine", readyIssues([][6]string{
{"q-1", privateMarker + " the quarantined plan", "open", "0", "dave", ""},
}), nil),
}}
}
t.Run("a grant this service does not recognise reads nothing", func(t *testing.T) {
repos, opener := readyFakes(fixture(core.AccessMode("ARCHIVED")))
got := readyWork(t, connect(t, newServer(t, repos, opener), carol()), nil)
assert.Empty(t, got.Databases)
assert.Zero(t, got.Total)
assert.Empty(t, got.Unreadable, "it is not a failure either: it is simply absent")
assert.Empty(t, opener.opened, "a database the access rule refuses is never opened")
})
t.Run("and the same row with a grant it does reads it", func(t *testing.T) {
repos, opener := readyFakes(fixture(core.AccessRO))
got := readyWork(t, connect(t, newServer(t, repos, opener), carol()), nil)
require.Len(t, got.Databases, 1, "so the refusal above is the mode and not the fixture")
assert.Equal(t, []string{"q-1"}, readyIDs(got.Databases[0]))
})
}
// --- the two arms agree -------------------------------------------------------
// The named arm is the cross arm restricted to one candidate, and this is where
// that is asserted rather than assumed: same ref, same head, same cards, same
// per-database count.
func TestReadyWorkNamedDatabaseAgreesWithTheCrossArm(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
across := readyDatabaseNamed(t, readyWork(t, session, nil), "alice/alpha")
named := readyWork(t, session, args("alpha"))
require.Len(t, named.Databases, 1, "one database was named, so one is answered about")
assert.Equal(t, across, named.Databases[0],
"one aggregator, two arms: naming a database may not change what it says")
assert.Equal(t, 2, named.Total)
assert.Equal(t, 1, named.Considered)
assert.False(t, named.Capped)
assert.Empty(t, named.Unreadable)
}
// An unlisted database is absent from the listing and answered when addressed —
// the rule the dashboard already implements, and the reason the two arms are not
// the same question.
func TestReadyWorkNamesAnUnlistedTrackerTheListingHides(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
assert.NotContains(t, readySlugs(readyWork(t, session, nil)), "frank/hidden")
named := readyWork(t, session, map[string]any{"owner": "frank", "name": "hidden"})
require.Len(t, named.Databases, 1)
assert.Equal(t, []string{"u-1"}, readyIDs(named.Databases[0]))
}
// --- the three bounds ---------------------------------------------------------
// The head-hash gate: a second call whose heads have not moved reads no rows at
// all. Counted on the fakes, never timed — and it holds only because the cache
// lives on the server rather than being built per call.
func TestReadyWorkHeadHashGateReadsNothingTwice(t *testing.T) {
server, _, opener := readyServer(t)
session := connect(t, server, nil)
alpha := opener.sessions[storePath("alice", "alpha")]
beta := opener.sessions[storePath("bob", "beta")]
sensors := opener.sessions[storePath("erin", "sensors")]
first := readyWork(t, session, nil)
reads := alpha.rowReads
require.Greater(t, reads, 0, "the first call must read rows")
second := readyWork(t, session, nil)
assert.Equal(t, reads, alpha.rowReads, "an unmoved head must cost no row reads")
assert.Equal(t, 1, alpha.tableReads, "nor a second table listing")
assert.Equal(t, 1, alpha.logReads, "nor a second log read")
assert.Equal(t, 1, beta.tableReads, "the sibling database is gated too")
assert.Equal(t, 1, sensors.tableReads,
"and so is the fingerprint of a database that is not a tracker")
assert.Equal(t, first, second, "the answer is the same answer, cache or not")
// Both stores are still opened and their branches listed: that is what the
// gate is gated on, and it is the cheap half.
assert.Equal(t, 2, countOpens(opener, storePath("alice", "alpha")))
}
// The two arms share one cache, which is the same statement as "they cannot
// disagree": a database read by the named arm is not read again by the cross
// arm while its head stands.
func TestReadyWorkSharesOneCacheBetweenTheArms(t *testing.T) {
server, _, opener := readyServer(t)
session := connect(t, server, nil)
alpha := opener.sessions[storePath("alice", "alpha")]
readyWork(t, session, args("alpha"))
reads := alpha.rowReads
require.Greater(t, reads, 0)
readyWork(t, session, nil)
assert.Equal(t, reads, alpha.rowReads, "the cross arm read what the named arm had cached")
}
// The ceiling: more candidate databases than beads.ReadyMaxDatabases and the
// answer says so, because a silent cap reads as "that is everything". The
// databases past it are never opened.
func TestReadyWorkReportsTheCeiling(t *testing.T) {
var fx []readyFixture
for i := 1; i <= beads.ReadyMaxDatabases+2; i++ {
name := fmt.Sprintf("db%02d", i)
fx = append(fx, readyFixture{
owner: "alice", ownerID: aliceID, name: name, vis: core.VisibilityPublic,
session: readyTracker(fmt.Sprintf("h%02d", i), readyIssues([][6]string{
{fmt.Sprintf("t%02d-1", i), "Ready", "open", "1", "alice", ""},
}), nil),
})
}
repos, opener := readyFakes(fx)
got := readyWork(t, connect(t, newServer(t, repos, opener), nil), nil)
assert.True(t, got.Capped, "there were more trackers than one call may open")
assert.Equal(t, beads.ReadyMaxDatabases, got.Considered)
assert.Equal(t, beads.ReadyMaxDatabases, got.MaxDatabases)
assert.Len(t, got.Databases, beads.ReadyMaxDatabases)
assert.Equal(t, beads.ReadyMaxDatabases, got.Total)
for _, f := range fx[beads.ReadyMaxDatabases:] {
assert.Zero(t, countOpens(opener, storePath(f.owner, f.name)),
"%s: a database past the ceiling is never opened", f.slug())
}
}
// --- what one broken database costs -------------------------------------------
// A store that will not open costs itself only: the rest of the answer is the
// answer, the gap is admitted by name, and the browse error reaches the log
// rather than the caller.
func TestReadyWorkSurvivesADatabaseThatCannotBeOpened(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
got := readyWork(t, session, nil)
assert.Equal(t, []string{"alice/alpha", "bob/beta"}, readySlugs(got))
assert.Equal(t, 3, got.Total)
assert.Equal(t, []readyUnreadableResult{{Owner: "erin", Name: "broken"}}, got.Unreadable,
"the gap is named so the set is readable as incomplete")
payload := resultJSON(t, call(t, session, "ready_work", nil))
assert.NotContains(t, payload, "/stores/", "no on-disk path reaches a caller")
assert.NotContains(t, payload, "no store at")
}
// A database that is not a beads tracker is skipped silently: no group, no note,
// and not a row read.
func TestReadyWorkSkipsNonBeadsDatabasesSilently(t *testing.T) {
server, _, opener := readyServer(t)
got := readyWork(t, connect(t, server, nil), nil)
assert.NotContains(t, readySlugs(got), "erin/sensors")
for _, u := range got.Unreadable {
assert.NotEqual(t, "sensors", u.Name, "not being a tracker is not a failure")
}
assert.Zero(t, opener.sessions[storePath("erin", "sensors")].rowReads,
"a database that is not a tracker is never read for rows")
}
// Naming one is the other half of the same rule: a database the caller is
// looking straight at, which is not a tracker, is refused with the sentence that
// names the generic tools — never an empty ready set, which would read as
// "nothing to do here".
func TestReadyWorkRefusesANamedDatabaseThatIsNotATracker(t *testing.T) {
server, _, _ := readyServer(t)
res := call(t, connect(t, server, nil), "ready_work",
map[string]any{"owner": "erin", "name": "sensors"})
require.True(t, res.IsError)
text := errorText(res)
assert.Contains(t, text, "~erin/sensors")
assert.Contains(t, text, "not a beads issue tracker")
assert.Contains(t, text, "list_tables", "and names the way to read it anyway")
assert.NotContains(t, text, "no database", "the database exists and this caller may read it")
}
// --- the filters --------------------------------------------------------------
// Each filter narrows the ready set exactly as the page's does: the filtering is
// beads.ReadyFilter's, and these are the cases that would catch a second
// implementation drifting from it.
func TestReadyWorkFiltersNarrowAsThePageDoes(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, bob())
for _, tc := range []struct {
name string
args map[string]any
want []string
total int
}{
{"q over the title", map[string]any{"q": "top priority"}, []string{"alice/alpha"}, 1},
{"q over the id", map[string]any{"q": "b-1"}, []string{"bob/beta"}, 1},
{"q is case-insensitive", map[string]any{"q": "READY, LOWER"}, []string{"alice/alpha"}, 1},
{"assignee", map[string]any{"assignee": "bob"}, []string{"alice/alpha"}, 1},
{"priority", map[string]any{"priority": "0"}, []string{"alice/alpha", "dave/secrets"}, 2},
{"two at once", map[string]any{"priority": "0", "assignee": "dave"}, []string{"dave/secrets"}, 1},
{"a filter nothing matches", map[string]any{"assignee": "nobody"}, nil, 0},
} {
t.Run(tc.name, func(t *testing.T) {
got := readyWork(t, session, tc.args)
assert.ElementsMatch(t, tc.want, readySlugs(got))
assert.Equal(t, tc.total, got.Total, "the total is the matched set, not the instance")
})
}
}
// The filters narrow one named database too, so the arms agree under a filter as
// well as without one.
func TestReadyWorkFiltersANamedDatabase(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
got := readyWork(t, session, args("alpha", "assignee", "bob"))
require.Len(t, got.Databases, 1)
assert.Equal(t, []string{"a-2"}, readyIDs(got.Databases[0]))
assert.Equal(t, 1, got.Databases[0].Count, "the per-database count is the filtered one")
}
// --- the cap ------------------------------------------------------------------
// The cap of docs/DESIGN.mcp.md §9.3, spent across databases: the answer carries
// the limit really applied, the true total, and whether anything was left
// behind. Each database keeps its own honest count.
func TestReadyWorkCapsTheLimitAndSaysSo(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
t.Run("a limit above the cap is answered at the cap", func(t *testing.T) {
got := readyWork(t, session, map[string]any{"limit": 5000})
assert.Equal(t, 500, got.Limit, "the cap, reported rather than silently applied")
assert.Equal(t, 3, got.Total)
assert.False(t, got.Truncated)
})
t.Run("a limit below the matches clips and says so", func(t *testing.T) {
got := readyWork(t, session, map[string]any{"limit": 1})
assert.Equal(t, 1, got.Limit)
require.Len(t, got.Databases, 1, "a database left with nothing is absent, not empty")
assert.Equal(t, []string{"a-1"}, readyIDs(got.Databases[0]))
assert.Equal(t, 2, got.Databases[0].Count, "and its own count is still the true one")
assert.Equal(t, 3, got.Total, "the honest denominator is every match")
assert.True(t, got.Truncated)
})
t.Run("the limit is spent across databases", func(t *testing.T) {
got := readyWork(t, session, map[string]any{"limit": 3})
assert.Equal(t, []string{"alice/alpha", "bob/beta"}, readySlugs(got))
assert.False(t, got.Truncated)
})
t.Run("a non-positive limit is refused", func(t *testing.T) {
for _, limit := range []int{0, -1} {
res := call(t, session, "ready_work", map[string]any{"limit": limit})
require.True(t, res.IsError, "limit %d", limit)
assert.Contains(t, errorText(res), "limit must be a positive number of issues")
}
})
}
// --- addressing ---------------------------------------------------------------
// Half an address is not an address. Naming neither is the cross-database arm and
// naming both is one database; naming one of the two means nothing and is refused
// rather than guessed at in either direction.
func TestReadyWorkNeedsBothHalvesOfAnAddress(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, nil)
for _, a := range []map[string]any{{"owner": "alice"}, {"name": "alpha"}} {
res := call(t, session, "ready_work", a)
require.True(t, res.IsError, "%v", a)
assert.Contains(t, errorText(res), "owner")
assert.Contains(t, errorText(res), "name")
}
}
// A database the caller may not read answers exactly as one that does not exist,
// which is the masked not-found the rest of the surface answers with: a refusal
// of its own shape would rebuild the distinction it exists to erase.
func TestReadyWorkMasksADatabaseTheCallerMayNotRead(t *testing.T) {
server, _, _ := readyServer(t)
session := connect(t, server, carol())
masked := call(t, session, "ready_work", map[string]any{"owner": "dave", "name": "secrets"})
require.True(t, masked.IsError)
absent := call(t, session, "ready_work", map[string]any{"owner": "dave", "name": "nosuch"})
require.True(t, absent.IsError)
assert.Equal(t,
strings.Replace(errorText(absent), "nosuch", "secrets", 1),
errorText(masked),
"a masked database answers exactly as one that does not exist")
assert.NotContains(t, errorText(masked), privateMarker)
}
// --- sessions and bodies ------------------------------------------------------
// One session per database per call, closed by the tool that opened it — in both
// arms. The named arm is the one worth counting twice: it opens the store for
// the fingerprint and then hands the *same* session to the aggregation, so a
// second open would be a store opened twice for one call, and a second close
// would be a handle closed twice.
func TestReadyWorkClosesEverySessionItOpened(t *testing.T) {
t.Run("across every tracker", func(t *testing.T) {
server, _, opener := readyServer(t)
readyWork(t, connect(t, server, nil), nil)
for _, f := range readyFixtures() {
if f.session == nil {
continue
}
path := storePath(f.owner, f.name)
if f.vis != core.VisibilityPublic {
assert.Zero(t, countOpens(opener, path),
"%s: a database this caller may not list is never opened", f.slug())
assert.Zero(t, f.session.closes)
continue
}
assert.Equal(t, 1, countOpens(opener, path), "%s: opened once", f.slug())
assert.Equal(t, 1, opener.sessions[path].closes, "%s: and closed once", f.slug())
}
})
t.Run("one named tracker", func(t *testing.T) {
server, _, opener := readyServer(t)
readyWork(t, connect(t, server, nil), args("alpha"))
assert.Equal(t, []string{storePath("alice", "alpha")}, opener.opened,
"exactly the one database the call named, opened exactly once")
assert.Equal(t, 1, opener.sessions[storePath("alice", "alpha")].closes,
"the session lent to the aggregation is closed by the handler that opened it, once")
})
}
// The list/detail split of docs/DESIGN.mcp.md §9.2 holds here too: this is a
// listing, so it carries identity and metadata and no prose. Asserted over the
// serialised payload, so a field added to the card later would carry a marker
// into it and turn this red.
func TestReadyWorkCarriesNoIssueBodies(t *testing.T) {
server, _, _ := readyServer(t)
payload := resultJSON(t, call(t, connect(t, server, nil), "ready_work", nil))
assert.Contains(t, payload, "a-1", "the fixture's body belongs to an issue that is in the answer")
assert.NotContains(t, payload, bodyMarker)
for _, field := range []string{"description", "design", "acceptance_criteria", "notes"} {
assert.NotContains(t, payload, `"`+field+`"`,
"the card type has no field a body could arrive in, and that is structural")
}
}
// countOpens is how many times a store path was opened, over the log the fake
// opener keeps.
func countOpens(o *fakeOpener, path string) int {
n := 0
for _, p := range o.opened {
if p == path {
n++
}
}
return n
}