package deliberi
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"tangled.org/core/api/org_tangled"
)
const collaboratorInvitesByBody = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","repo":"did:plc:scallop","addedBy":"did:plc:boltless","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`
func serving(t *testing.T, path, body string) *bobbinClient {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != path {
t.Errorf("path = %q, want %q", r.URL.Path, path)
http.NotFound(w, r)
return
}
if r.URL.Query().Get("subject") == "" {
t.Error("subject param missing; bobbin requires it")
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
t.Cleanup(srv.Close)
return newBobbinClient(srv.URL)
}
func TestListCollaboratorInvitesByDecodesBobbinsBody(t *testing.T) {
c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, collaboratorInvitesByBody)
answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet")
if err != nil {
t.Fatalf("ListCollaboratorInvitesBy: %v", err)
}
want := InviteOffer{
Uri: "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet",
KnotDid: "did:web:knot.oyster.cafe",
RepoDid: "did:plc:scallop",
AddedBy: "did:plc:boltless",
CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
}
if len(answer.Offers) != 1 || answer.Offers[0] != want {
t.Fatalf("offers = %+v, want one %+v", answer.Offers, want)
}
}
func TestPartialAnswerListsWhatBobbinCouldntRead(t *testing.T) {
const body = `{"items":[],"pending":["did:web:cold.knot","did:web:other.knot"],"truncated":true}`
c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)
answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet")
if err != nil {
t.Fatalf("ListCollaboratorInvitesBy: %v", err)
}
if len(answer.Pending) != 2 {
t.Fatalf("pending = %v, want both knots", answer.Pending)
}
if !answer.Truncated {
t.Fatal("truncated dropped in decode, so we'd read cut list as whole offer set")
}
}
func TestUnreadableCreatedAtFailsListing(t *testing.T) {
const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"whenever"}]}`
c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)
if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
t.Fatal("we can't stamp rows from timestamps that won't read")
}
}
func TestOfferMissingItsKnotFailsListing(t *testing.T) {
const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`
c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)
if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
t.Fatal("offers missing their knot can't be checked against pending, so accepting one would make its absence look like withdrawal")
}
}
func TestOfferMissingItsRepoFailsListing(t *testing.T) {
const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`
c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)
if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
t.Fatal("a repo-less offer has nothing to link or accept against, so the row would be unusable")
}
}
func TestWarming503FailsListing(t *testing.T) {
const warming = `{"error":"Warming","message":"invite index is still warming, so it can't list offers yet"}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, warming)
}))
t.Cleanup(srv.Close)
c := newBobbinClient(srv.URL)
if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
t.Fatal("503 decoded as empty listing, so reconcile would read every offer as withdrawn")
}
}
func TestRepoOwnerReadsManifestDeclaration(t *testing.T) {
// the manifest lives in the repo's own account, so the uri authority is the
// repo did and must not be mistaken for the owner
const body = `{"uri":"at://did:plc:therepo/org.tangled.repo.manifest/self","cid":"bafyfake","knot":"did:web:knot.example",` +
`"value":{"$type":"org.tangled.repo.manifest","createdAt":"2026-01-01T00:00:00Z","declaration":{"owner":"did:plc:bob","name":"my-repo"}}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("repoDid"); got != "did:plc:therepo" {
t.Errorf("repoDid param = %q", got)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
t.Cleanup(srv.Close)
owner, name, err := newBobbinClient(srv.URL).RepoOwner(context.Background(), "did:plc:therepo")
if err != nil {
t.Fatalf("RepoOwner: %v", err)
}
if owner != "did:plc:bob" || name != "my-repo" {
t.Fatalf("owner/name = %q/%q, want did:plc:bob/my-repo", owner, name)
}
}