package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-spec/api"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// fakeWriter captures the request the handler builds and returns a canned
// result or error, so routing, header parsing and status mapping are checked
// without a service, a repository or a database.
type fakeWriter struct {
got service.ProposeRequest
res service.ProposeResult
err error
}
func (f *fakeWriter) Propose(_ context.Context, req service.ProposeRequest) (service.ProposeResult, error) {
f.got = req
return f.res, f.err
}
// router builds the write routes with a principal injected into every request,
// bypassing token resolution — the handler reads the principal off the context,
// and what put it there is not this package's concern.
func router(t *testing.T, w api.Writer, p authn.Principal) http.Handler {
t.Helper()
srv, err := api.New(api.Options{Writer: w, Resolver: testResolver(t)})
if err != nil {
t.Fatalf("api.New: %v", err)
}
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next.ServeHTTP(rw, req.WithContext(authn.WithPrincipal(req.Context(), p)))
})
})
srv.Register(r)
return r
}
// testResolver is a resolver New requires but Register does not use. One token
// store with no tokens is enough to construct it.
func testResolver(t *testing.T) *authn.Resolver {
t.Helper()
res, err := authn.NewResolver("bigbes", stubTokens{})
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
return res
}
type stubTokens struct{}
func (stubTokens) LookupAgentToken(context.Context, []byte) (authn.AgentToken, error) {
return authn.AgentToken{}, authn.ErrUnknownToken
}
func agent() authn.Principal {
return authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code", Session: "s1"}
}
const specPath = "/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md"
// TestPutOpensProposal proves the whole open path: a PUT with a body and an
// If-Match opens a proposal, returns 201 with the proposal and its url, and
// forwards every field to the service.
func TestPutOpensProposal(t *testing.T) {
w := &fakeWriter{res: service.ProposeResult{
Proposal: service.Proposal{ID: 42, Branch: "proposals/42", BaseRev: "deadbeef", State: core.StateOpen},
URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/42",
}}
h := router(t, w, agent())
req := httptest.NewRequest(http.MethodPut, specPath+"?title=Storage&message=add+it", strings.NewReader("the document"))
req.Header.Set("If-Match", "1f0c1d1a")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body)
}
var body struct {
Proposal int `json:"proposal"`
URL string `json:"url"`
State string `json:"state"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Proposal != 42 || !strings.HasSuffix(body.URL, "/p/42") || body.State != "open" {
t.Fatalf("body = %+v, want proposal 42 open with its url", body)
}
// The request the service saw.
g := w.got
if g.Space != (core.SpaceRef{Owner: "bigbes", Name: "rfcs"}) {
t.Errorf("space = %v", g.Space)
}
if g.IfMatch != "1f0c1d1a" || g.Title != "Storage" || g.Message != "add it" {
t.Errorf("forwarded fields wrong: %+v", g)
}
if g.ProposalID != 0 {
t.Errorf("ProposalID = %d, want 0 for an open", g.ProposalID)
}
if len(g.Writes) != 1 || g.Writes[0].Path != "specs/0007-storage.md" || string(g.Writes[0].Content) != "the document" {
t.Errorf("writes = %+v", g.Writes)
}
if g.Principal != agent() {
t.Errorf("principal = %+v, want the agent on the context", g.Principal)
}
}
// TestPutAddsToExistingProposal proves X-Proposal routes to an add and returns
// 200 rather than 201.
func TestPutAddsToExistingProposal(t *testing.T) {
w := &fakeWriter{res: service.ProposeResult{
Proposal: service.Proposal{ID: 7, Branch: "proposals/7", State: core.StateOpen},
URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/7",
}}
h := router(t, w, agent())
req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
req.Header.Set("If-Match", "1f0c1d1a")
req.Header.Set("X-Proposal", "7")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body %s", rec.Code, rec.Body)
}
if w.got.ProposalID != 7 {
t.Errorf("ProposalID = %d, want 7", w.got.ProposalID)
}
}
// TestPutRequiresIfMatch refuses a write with no base.
func TestPutRequiresIfMatch(t *testing.T) {
w := &fakeWriter{}
h := router(t, w, agent())
req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if w.got.IfMatch != "" || w.got.Space != (core.SpaceRef{}) {
t.Fatal("service was called despite a missing If-Match")
}
}
// TestPutRejectsBadProposalHeader refuses a non-numeric X-Proposal.
func TestPutRejectsBadProposalHeader(t *testing.T) {
h := router(t, &fakeWriter{}, agent())
req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
req.Header.Set("If-Match", "1f0c1d1a")
req.Header.Set("X-Proposal", "not-a-number")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// TestPutMapsServiceErrors proves each service sentinel reaches the right status.
func TestPutMapsServiceErrors(t *testing.T) {
tests := []struct {
name string
err error
want int
}{
{"forbidden", service.ErrForbidden, http.StatusForbidden},
{"invalid", service.ErrInvalid, http.StatusUnprocessableEntity},
{"stale", service.ErrStale, http.StatusConflict},
{"already merged", service.ErrAlreadyMerged, http.StatusConflict},
{"not open", service.ErrProposalNotOpen, http.StatusConflict},
{"not found", service.ErrNotFound, http.StatusNotFound},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := router(t, &fakeWriter{err: tc.err}, agent())
req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc"))
req.Header.Set("If-Match", "1f0c1d1a")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
// TestPutDecodesEncodedPath proves a percent-encoded document path reaches the
// service decoded, so a name with a space or a Cyrillic letter is addressable.
func TestPutDecodesEncodedPath(t *testing.T) {
w := &fakeWriter{res: service.ProposeResult{Proposal: service.Proposal{ID: 1, State: core.StateOpen}}}
h := router(t, w, agent())
req := httptest.NewRequest(http.MethodPut, "/v1/spaces/~bigbes/rfcs/docs/notes/hello%20world.md", strings.NewReader("doc"))
req.Header.Set("If-Match", "1f0c1d1a")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body)
}
if len(w.got.Writes) != 1 || w.got.Writes[0].Path != "notes/hello world.md" {
t.Fatalf("path = %q, want decoded", w.got.Writes[0].Path)
}
}