package ops
import (
"context"
"strings"
"testing"
)
type echoIn struct {
Repo string `json:"repo"`
}
type echoOut struct {
Repo string `json:"repo"`
Count int `json:"count"`
}
func echoOp(name, route string) Op[echoIn, echoOut] {
return Op[echoIn, echoOut]{
Name: name,
Doc: "echo test op",
Kind: Query,
Auth: Anonymous,
Route: route,
Run: func(_ context.Context, in echoIn) (echoOut, error) {
return echoOut{Repo: in.Repo, Count: 1}, nil
},
}
}
func TestRegisterAndInvoke(t *testing.T) {
r := NewRegistry()
if err := Register(r, echoOp("timeline.list", "GET /api/v1/r/{repo}/timeline")); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(context.Background(), "timeline.list", echoIn{Repo: "sqlite"})
if err != nil {
t.Fatal(err)
}
out, ok := got.(echoOut)
if !ok {
t.Fatalf("got %T, want echoOut", got)
}
if out.Repo != "sqlite" || out.Count != 1 {
t.Fatalf("got %+v", out)
}
}
func TestInvokeWrongInputType(t *testing.T) {
r := NewRegistry()
if err := Register(r, echoOp("timeline.list", "GET /timeline")); err != nil {
t.Fatal(err)
}
if _, err := r.Invoke(context.Background(), "timeline.list", struct{ X int }{}); err == nil {
t.Fatal("want type-mismatch error, got nil")
}
}
func TestInvokeUnknownOp(t *testing.T) {
r := NewRegistry()
if _, err := r.Invoke(context.Background(), "nope", echoIn{}); err == nil {
t.Fatal("want unknown-op error, got nil")
}
}
func TestDuplicateNameRejected(t *testing.T) {
r := NewRegistry()
if err := Register(r, echoOp("dup", "GET /a")); err != nil {
t.Fatal(err)
}
err := Register(r, echoOp("dup", "GET /b"))
if err == nil || !strings.Contains(err.Error(), "duplicate op name") {
t.Fatalf("want duplicate-name error, got %v", err)
}
}
func TestDuplicateRouteRejected(t *testing.T) {
r := NewRegistry()
if err := Register(r, echoOp("a", "GET /same")); err != nil {
t.Fatal(err)
}
err := Register(r, echoOp("b", "GET /same"))
if err == nil || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("want duplicate-route error, got %v", err)
}
}
func TestKindMethodMismatchRejected(t *testing.T) {
r := NewRegistry()
q := echoOp("q", "POST /q") // query must be GET
if err := Register(r, q); err == nil {
t.Fatal("want query/POST mismatch error, got nil")
}
c := echoOp("c", "GET /c")
c.Kind = Command // command must be POST
if err := Register(r, c); err == nil {
t.Fatal("want command/GET mismatch error, got nil")
}
}
func TestBadRoutesRejected(t *testing.T) {
for _, route := range []string{"", "GET", "GET relative/path", "DELETE /x", "/x"} {
r := NewRegistry()
if err := Register(r, echoOp("x", route)); err == nil {
t.Fatalf("route %q: want error, got nil", route)
}
}
}
func TestNonStructInRejected(t *testing.T) {
r := NewRegistry()
err := Register(r, Op[string, echoOut]{
Name: "bad",
Doc: "d",
Kind: Query,
Route: "GET /bad",
Run: func(_ context.Context, _ string) (echoOut, error) { return echoOut{}, nil },
})
if err == nil || !strings.Contains(err.Error(), "In must be a struct") {
t.Fatalf("want non-struct error, got %v", err)
}
}
func TestNilRunRejected(t *testing.T) {
r := NewRegistry()
err := Register(r, Op[echoIn, echoOut]{Name: "nilrun", Doc: "d", Kind: Query, Route: "GET /n"})
if err == nil || !strings.Contains(err.Error(), "nil Run") {
t.Fatalf("want nil-Run error, got %v", err)
}
}
func TestAllSortedByName(t *testing.T) {
r := NewRegistry()
for _, n := range []string{"c.op", "a.op", "b.op"} {
if err := Register(r, echoOp(n, "GET /"+n)); err != nil {
t.Fatal(err)
}
}
all := r.All()
if len(all) != 3 || all[0].Name != "a.op" || all[1].Name != "b.op" || all[2].Name != "c.op" {
names := make([]string, len(all))
for i, d := range all {
names[i] = d.Name
}
t.Fatalf("got %v", names)
}
}
// An operator path is a COMMAND on ONE REPOSITORY with letters to stand
// in for: the witness journals commands only, and an op with no letters
// or no repository has nothing for the operator to be let past (#318).
func TestAnOperatorOpIsAGatedRepositoryCommand(t *testing.T) {
for _, tc := range []struct {
name string
op Op[echoIn, echoOut]
}{
{"a query", func() Op[echoIn, echoOut] {
op := echoOp("q", "GET /api/v1/r/{repo}/q")
op.Caps, op.Operator = "a", true
return op
}()},
{"no letters", func() Op[echoIn, echoOut] {
op := echoOp("c", "POST /api/v1/r/{repo}/c")
op.Kind, op.Operator = Command, true
return op
}()},
{"the fleet", func() Op[echoIn, echoOut] {
op := echoOp("f", "POST /api/v1/f")
op.Kind, op.Caps, op.Scope, op.Operator = Command, "s", ScopeFleet, true
return op
}()},
} {
if err := Register(NewRegistry(), tc.op); err == nil {
t.Errorf("FAIL: an operator op that is %s was registered", tc.name)
}
}
op := echoOp("ok", "POST /api/v1/r/{repo}/ok")
op.Kind, op.Caps, op.Operator = Command, "a", true
if err := Register(NewRegistry(), op); err != nil {
t.Errorf("FAIL: a gated repository command was refused as an operator op: %v", err)
}
}
// The witness is asked BEFORE a command runs, so it can read what the
// command is about to change, and told after it only when it succeeded.
func TestTheWitnessIsAskedBeforeAndToldAfter(t *testing.T) {
r := NewRegistry()
var seq []string
op := echoOp("c", "POST /api/v1/r/{repo}/c")
op.Kind = Command
op.Run = func(_ context.Context, in echoIn) (echoOut, error) {
seq = append(seq, "run")
if in.Repo == "fail" {
return echoOut{}, context.Canceled
}
return echoOut{}, nil
}
if err := Register(r, op); err != nil {
t.Fatal(err)
}
r.Observe(func(context.Context, string, any) func() {
seq = append(seq, "before")
return func() { seq = append(seq, "after") }
})
if _, err := r.Invoke(context.Background(), "c", echoIn{Repo: "ok"}); err != nil {
t.Fatal(err)
}
r.Invoke(context.Background(), "c", echoIn{Repo: "fail"})
if got := strings.Join(seq, " "); got != "before run after before run" {
t.Fatalf("FAIL: witness order %q", got)
}
}