blob: 1c5d818ecd97363f90c15d0738ed9f5c28cabb44 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package actions
import (
"bytes"
"cmp"
"context"
"errors"
"fmt"
"reflect"
"slices"
"testing"
"time"
"golang.org/x/oscar/internal/storage"
"golang.org/x/oscar/internal/storage/timed"
"golang.org/x/oscar/internal/testutil"
"rsc.io/ordered"
)
func TestDB(t *testing.T) {
var (
actionKind = "test"
key = ordered.Encode("num", 23)
action = []byte("action")
result = []byte("result")
anError = errors.New("bad")
)
t.Run("before", func(t *testing.T) {
db := storage.MemDB()
if !before(db, actionKind, key, action, !RequiresApproval) {
t.Fatal("already added")
}
e, ok := Get(db, actionKind, key)
if !ok {
t.Fatal("not found")
}
want := &Entry{
Created: e.Created,
Kind: actionKind,
Key: key,
Action: action,
ApprovalRequired: false,
ModTime: e.ModTime,
}
if !reflect.DeepEqual(e, want) {
t.Errorf("Before:\ngot %+v\nwant %+v", e, want)
}
if before(db, actionKind, key, action, !RequiresApproval) {
t.Error("got added for existing action")
}
})
t.Run("get not found", func(t *testing.T) {
db := storage.MemDB()
if _, ok := Get(db, actionKind, key); ok {
t.Fatal("action present, should be missing")
}
})
t.Run("approval", func(t *testing.T) {
db := storage.MemDB()
if !before(db, actionKind, key, action, RequiresApproval) {
t.Fatal("already added")
}
tm := time.Now().Round(0).In(time.UTC)
d1 := Decision{Name: "name1", Time: tm, Approved: true}
d2 := Decision{Name: "name2", Time: tm, Approved: false}
AddDecision(db, actionKind, key, d1)
AddDecision(db, actionKind, key, d2)
e, ok := Get(db, actionKind, key)
if !ok {
t.Fatal("not found")
}
want := &Entry{
Created: e.Created,
ModTime: e.ModTime,
Kind: actionKind,
Key: key,
Action: action,
ApprovalRequired: true,
Decisions: []Decision{d1, d2},
}
if !reflect.DeepEqual(e, want) {
t.Errorf("\ngot: %+v\nwant: %+v", e, want)
}
})
t.Run("scan", func(t *testing.T) {
eqEntry := func(e1, e2 *Entry) bool {
return reflect.DeepEqual(e1, e2)
}
db := storage.MemDB()
lg := testutil.Slogger(t)
var entries []*Entry
start := time.Now()
for i := 1; i <= 3; i++ {
e := &Entry{
Kind: fmt.Sprintf("test-%d", i%2),
Key: ordered.Encode(i),
Action: []byte{byte(-i)},
}
time.Sleep(50 * time.Millisecond) // ensure each action has a different wall clock time
if !before(db, e.Kind, e.Key, e.Action, !RequiresApproval) {
t.Fatal("already added")
}
entries = append(entries, e)
}
entriesByKey := slices.Clone(entries)
slices.SortFunc(entriesByKey, func(e1, e2 *Entry) int {
return cmp.Or(
cmp.Compare(e1.Kind, e2.Kind),
bytes.Compare(e1.Key, e2.Key),
)
})
got := slices.Collect(Scan(db, nil, ordered.Encode(ordered.Inf)))
for i, g := range got {
if i < len(entriesByKey) {
entriesByKey[i].Created = g.Created
entriesByKey[i].ModTime = g.ModTime
}
}
compareSlices(t, got, entriesByKey, eqEntry)
got = slices.Collect(ScanAfterDBTime(lg, db, 0, nil))
compareSlices(t, got, entries, func(e1, e2 *Entry) bool {
return reflect.DeepEqual(e1, e2)
})
// Test filter.
got = slices.Collect(ScanAfterDBTime(lg, db, 0, func(string, []byte) bool { return false }))
if len(got) > 0 {
t.Error("got entries, want none")
}
// Test that early break doesn't panic.
for range Scan(db, nil, ordered.Encode(ordered.Inf)) {
break
}
for range ScanAfterDBTime(lg, db, 0, nil) {
break
}
for _, test := range []struct {
t time.Time
want []*Entry
}{
{start, entries},
{time.Now(), nil},
{entries[0].Created, entries[1:]},
} {
got := slices.Collect(ScanAfter(lg, db, test.t, nil))
compareSlices(t, got, test.want, eqEntry)
}
})
t.Run("registerAndRun", func(t *testing.T) {
var gotAction []byte
before := Register(actionKind, testActioner{
run: func(_ context.Context, action []byte) ([]byte, error) {
gotAction = action
return result, anError
},
})
db := storage.MemDB()
if !before(db, key, action, !RequiresApproval) {
t.Fatal("already added")
}
e, ok := getEntry(db, dbKey(actionKind, key))
if !ok {
t.Fatal("missing entry")
}
runEntry(context.Background(), testutil.Slogger(t), db, e)
if !bytes.Equal(gotAction, action) {
t.Fatalf("got %q, want %q", gotAction, action)
}
e, ok = getEntry(db, dbKey(actionKind, key))
if !ok {
t.Fatal("not found")
}
if !bytes.Equal(e.Result, result) || e.Error != anError.Error() {
t.Errorf("got (%q, %q), want (%q, %q)", e.Result, e.Error, result, anError)
}
})
}
func compareSlices[T any](t *testing.T, got, want []T, eq func(T, T) bool) {
t.Helper()
for i := range max(len(got), len(want)) {
if i >= len(got) {
t.Errorf("%d: missing got", i)
} else if i >= len(want) {
t.Errorf("%d: missing want", i)
} else if !eq(got[i], want[i]) {
t.Errorf("%d:\ngot %+v\nwant %+v", i, got[i], want[i])
}
}
}
func TestApproved(t *testing.T) {
approve := Decision{Name: "n", Time: time.Now(), Approved: true}
deny := Decision{Name: "n", Time: time.Now(), Approved: false}
for _, test := range []struct {
req bool
ds []Decision
want bool
}{
{false, nil, true}, // approval not required => approved
{false, []Decision{deny}, true}, // ...even if there are denials.
{true, nil, false},
{true, []Decision{approve}, true},
{true, []Decision{approve, approve}, true},
{true, []Decision{approve, deny, approve}, false}, // denials have veto power
} {
e := &Entry{
ApprovalRequired: test.req,
Decisions: test.ds,
}
if got := e.Approved(); got != test.want {
t.Errorf("%+v: got %t, want %t", e, got, test.want)
}
}
}
func TestRun(t *testing.T) {
ctx := context.Background()
const actionKind = "akind"
key := ordered.Encode("key")
var errAction = errors.New("action failed")
lg := testutil.Slogger(t)
nRunCalls := 0
before := Register(actionKind, testActioner{
run: func(_ context.Context, action []byte) ([]byte, error) {
nRunCalls++
if string(action) == "fail" {
return nil, errAction
}
return append([]byte("result "), action...), nil
},
})
t.Run("basic", func(t *testing.T) {
db := storage.MemDB()
actions := []string{"a1", "a2", "fail"}
for i, a := range actions {
before(db, ordered.Encode(i), []byte(a), !RequiresApproval)
}
err := Run(ctx, lg, db)
// Expect one error, the failed action.
errs := err.(interface{ Unwrap() []error }).Unwrap()
if len(errs) != 1 || errs[0] != errAction {
t.Fatalf("wanted one errAction, got %+v", errs)
}
// There should be no pending actions.
for range timed.ScanAfter(lg, db, pendingKind, 0, nil) {
t.Fatal("there are still pending actions")
}
// The log should contain all the executed actions and their results.
var want []*Entry
for i := range len(actions) {
want = append(want, &Entry{
Key: ordered.Encode(i),
Action: []byte(actions[i]),
Result: []byte("result " + actions[i]),
Error: "",
})
}
want[2].Result = nil
want[2].Error = "action failed"
got := slices.Collect(ScanAfter(lg, db, time.Time{}, nil))
compareSlices(t, got, want, func(g, w *Entry) bool {
return bytes.Equal(g.Key, w.Key) &&
bytes.Equal(g.Action, w.Action) &&
!g.Done.IsZero() &&
bytes.Equal(g.Result, w.Result) &&
g.Error == w.Error &&
!g.ApprovalRequired &&
len(g.Decisions) == 0
})
})
t.Run("actions are run only once", func(t *testing.T) {
check := testutil.Checker(t)
nRunCalls = 0
db := storage.MemDB()
before(db, key, nil, !RequiresApproval)
check(Run(ctx, lg, db))
check(Run(ctx, lg, db))
if nRunCalls != 1 {
t.Fatalf("got %d calls, want 1", nRunCalls)
}
e, ok := Get(db, actionKind, key)
if !ok || !e.IsDone() {
t.Fatal("entry not done")
}
})
t.Run("(un)approved actions are (not) run", func(t *testing.T) {
check := testutil.Checker(t)
nRunCalls = 0
db := storage.MemDB()
checkRunAndDone := func(key []byte, wantRun bool) {
t.Helper()
check(Run(ctx, lg, db))
wantN := 0
wantDone := false
if wantRun {
wantN = 1
wantDone = true
}
if nRunCalls != wantN {
t.Errorf("got %d calls, want %d", nRunCalls, wantN)
}
e, ok := Get(db, actionKind, key)
if !ok {
t.Fatal("action not found")
}
if got := e.IsDone(); got != wantDone {
t.Errorf("done = %t, want %t", got, wantDone)
}
}
// unapproved, not run
before(db, key, nil, RequiresApproval)
checkRunAndDone(key, false)
// denied, still not run
AddDecision(db, actionKind, key, Decision{Approved: false})
checkRunAndDone(key, false)
// denied and approved, still not run
AddDecision(db, actionKind, key, Decision{Approved: true})
checkRunAndDone(key, false)
// approved, run
// We can't remove a decision, so make a new action.
key2 := ordered.Encode("key2")
before(db, key2, nil, RequiresApproval)
AddDecision(db, actionKind, key2, Decision{Approved: true})
checkRunAndDone(key2, true)
})
}
type testActioner struct {
Actioner
run func(context.Context, []byte) ([]byte, error)
}
func (t testActioner) Run(ctx context.Context, data []byte) ([]byte, error) {
return t.run(ctx, data)
}