- Add Attempt, Reason, OccurredAt fields to notification.TaskEvent - Populate workflow context in scheduler event emission via parseTaskEventContext - Extend proto-socket task.status.changed payload with additive fields - Update contracts note with new event payload shape - Archive notification_model subtask to agent-task/archive - Fix Plane compose path in core domain rules
486 lines
15 KiB
Go
486 lines
15 KiB
Go
package protosocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"github.com/jackc/pgx/v5"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
"nhooyr.io/websocket"
|
|
|
|
apphttp "github.com/nomadcode/nomadcode-core/internal/http"
|
|
"github.com/nomadcode/nomadcode-core/internal/notification"
|
|
"github.com/nomadcode/nomadcode-core/internal/storage"
|
|
"github.com/nomadcode/nomadcode-core/internal/workflow"
|
|
)
|
|
|
|
type fakeTaskService struct {
|
|
created workflow.CreateTaskInput
|
|
createOut storage.Task
|
|
createErr error
|
|
|
|
listLimit int32
|
|
listOut []storage.Task
|
|
listErr error
|
|
|
|
getID string
|
|
getOut storage.Task
|
|
getErr error
|
|
|
|
enqueueID string
|
|
enqueueOut storage.Task
|
|
enqueueErr error
|
|
}
|
|
|
|
func (f *fakeTaskService) CreateTask(_ context.Context, in workflow.CreateTaskInput) (storage.Task, error) {
|
|
f.created = in
|
|
return f.createOut, f.createErr
|
|
}
|
|
|
|
func (f *fakeTaskService) ListTasks(_ context.Context, limit int32) ([]storage.Task, error) {
|
|
f.listLimit = limit
|
|
return f.listOut, f.listErr
|
|
}
|
|
|
|
func (f *fakeTaskService) GetTask(_ context.Context, id string) (storage.Task, error) {
|
|
f.getID = id
|
|
return f.getOut, f.getErr
|
|
}
|
|
|
|
func (f *fakeTaskService) EnqueueTask(_ context.Context, id string) (storage.Task, error) {
|
|
f.enqueueID = id
|
|
return f.enqueueOut, f.enqueueErr
|
|
}
|
|
|
|
func dispatchTo(fake TaskService, req Envelope) Envelope {
|
|
d := NewDispatcher()
|
|
NewTaskChannels(fake).Register(d)
|
|
return d.Dispatch(context.Background(), req)
|
|
}
|
|
|
|
func TestTaskCreateActionCallsWorkflowAndReturnsResponse(t *testing.T) {
|
|
fake := &fakeTaskService{
|
|
createOut: storage.Task{ID: "task-1", Status: "pending", Title: "My Task", Source: "client"},
|
|
}
|
|
|
|
res := dispatchTo(fake, Envelope{
|
|
ID: "r1",
|
|
Type: "request",
|
|
Channel: "task",
|
|
Action: "task.create",
|
|
Payload: map[string]any{
|
|
"title": "My Task",
|
|
"source": "client",
|
|
"payload": map[string]any{},
|
|
},
|
|
})
|
|
|
|
if fake.created.Title != "My Task" || fake.created.Source != "client" {
|
|
t.Fatalf("workflow not called with expected input: %+v", fake.created)
|
|
}
|
|
if res.Type != "response" {
|
|
t.Fatalf("expected response type, got %q (err=%+v)", res.Type, res.Error)
|
|
}
|
|
if res.CorrelationID != "r1" {
|
|
t.Errorf("expected correlation_id r1, got %q", res.CorrelationID)
|
|
}
|
|
if res.Payload["id"] != "task-1" || res.Payload["status"] != "pending" {
|
|
t.Errorf("unexpected response payload: %+v", res.Payload)
|
|
}
|
|
if _, ok := res.Payload["task"].(map[string]any); !ok {
|
|
t.Errorf("expected nested task map in response payload: %+v", res.Payload)
|
|
}
|
|
}
|
|
|
|
func TestTaskListActionForwardsLimitToService(t *testing.T) {
|
|
fake := &fakeTaskService{
|
|
listOut: []storage.Task{{ID: "task-1"}, {ID: "task-2"}},
|
|
}
|
|
|
|
res := dispatchTo(fake, Envelope{
|
|
ID: "r2",
|
|
Type: "request",
|
|
Channel: "task",
|
|
Action: "task.list",
|
|
Payload: map[string]any{"limit": float64(5)},
|
|
})
|
|
|
|
if fake.listLimit != 5 {
|
|
t.Errorf("expected limit 5 forwarded to service, got %d", fake.listLimit)
|
|
}
|
|
if res.Type != "response" {
|
|
t.Fatalf("expected response type, got %q", res.Type)
|
|
}
|
|
tasks, ok := res.Payload["tasks"].([]any)
|
|
if !ok || len(tasks) != 2 {
|
|
t.Errorf("expected 2 tasks in payload, got %+v", res.Payload["tasks"])
|
|
}
|
|
|
|
// Omitted limit forwards 0; the workflow service owns default/bounding.
|
|
resDefault := dispatchTo(fake, Envelope{ID: "r2b", Action: "task.list", Channel: "task", Payload: map[string]any{}})
|
|
if fake.listLimit != 0 {
|
|
t.Errorf("expected omitted limit to forward 0, got %d", fake.listLimit)
|
|
}
|
|
if resDefault.Type != "response" {
|
|
t.Errorf("expected response type for default limit, got %q", resDefault.Type)
|
|
}
|
|
}
|
|
|
|
func TestTaskGetActionMapsNotFoundError(t *testing.T) {
|
|
fake := &fakeTaskService{getErr: pgx.ErrNoRows}
|
|
|
|
res := dispatchTo(fake, Envelope{
|
|
ID: "r3",
|
|
Action: "task.get",
|
|
Channel: "task",
|
|
Payload: map[string]any{"id": "missing"},
|
|
})
|
|
|
|
if fake.getID != "missing" {
|
|
t.Errorf("expected get called with 'missing', got %q", fake.getID)
|
|
}
|
|
if res.Type != "error" {
|
|
t.Fatalf("expected error type, got %q", res.Type)
|
|
}
|
|
if res.Error == nil || res.Error.Code != "task.not_found" {
|
|
t.Errorf("expected task.not_found, got %+v", res.Error)
|
|
}
|
|
if res.Error.Retryable {
|
|
t.Error("expected not_found to be non-retryable")
|
|
}
|
|
}
|
|
|
|
func TestTaskEnqueueActionMapsConflictError(t *testing.T) {
|
|
fake := &fakeTaskService{enqueueErr: workflow.ErrTaskCannotBeEnqueued}
|
|
|
|
res := dispatchTo(fake, Envelope{
|
|
ID: "r4",
|
|
Action: "task.enqueue",
|
|
Channel: "task",
|
|
Payload: map[string]any{"id": "task-9"},
|
|
})
|
|
|
|
if res.Type != "error" {
|
|
t.Fatalf("expected error type, got %q", res.Type)
|
|
}
|
|
if res.Error == nil || res.Error.Code != "task.conflict" {
|
|
t.Errorf("expected task.conflict, got %+v", res.Error)
|
|
}
|
|
}
|
|
|
|
func TestTaskActionRejectsInvalidPayload(t *testing.T) {
|
|
fake := &fakeTaskService{}
|
|
|
|
res := dispatchTo(fake, Envelope{
|
|
ID: "r5",
|
|
Action: "task.get",
|
|
Channel: "task",
|
|
Payload: map[string]any{"id": float64(123)}, // wrong type for string id
|
|
})
|
|
|
|
if res.Type != "error" {
|
|
t.Fatalf("expected error type, got %q", res.Type)
|
|
}
|
|
if res.Error == nil || res.Error.Code != "task.invalid_payload" {
|
|
t.Errorf("expected task.invalid_payload, got %+v", res.Error)
|
|
}
|
|
if fake.getID != "" {
|
|
t.Errorf("service should not be called on invalid payload, got %q", fake.getID)
|
|
}
|
|
}
|
|
|
|
type captureBroadcaster struct {
|
|
env Envelope
|
|
err error
|
|
}
|
|
|
|
func (c *captureBroadcaster) BroadcastEnvelope(_ context.Context, env Envelope) error {
|
|
c.env = env
|
|
return c.err
|
|
}
|
|
|
|
func TestHandleTaskEventBroadcastsStatusChangedEnvelope(t *testing.T) {
|
|
cap := &captureBroadcaster{}
|
|
sink := NewTaskEventBroadcaster(cap)
|
|
|
|
now := time.Now().UTC()
|
|
err := sink.HandleTaskEvent(context.Background(), notification.TaskEvent{
|
|
Type: notification.TaskEventRunning,
|
|
TaskID: "task-7",
|
|
Title: "Run me",
|
|
Status: "running",
|
|
Message: "started",
|
|
Attempt: 2,
|
|
Reason: "retry-on-failure",
|
|
OccurredAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if cap.env.Type != "event" || cap.env.Channel != "task" || cap.env.Action != "task.status.changed" {
|
|
t.Fatalf("unexpected envelope: type=%q channel=%q action=%q", cap.env.Type, cap.env.Channel, cap.env.Action)
|
|
}
|
|
if cap.env.Payload["id"] != "task-7" || cap.env.Payload["status"] != "running" {
|
|
t.Errorf("unexpected event payload: %+v", cap.env.Payload)
|
|
}
|
|
if cap.env.Payload["type"] != string(notification.TaskEventRunning) {
|
|
t.Errorf("expected type %q, got %v", notification.TaskEventRunning, cap.env.Payload["type"])
|
|
}
|
|
if cap.env.Payload["attempt"] != 2 {
|
|
t.Errorf("expected attempt 2, got %v", cap.env.Payload["attempt"])
|
|
}
|
|
if cap.env.Payload["reason"] != "retry-on-failure" {
|
|
t.Errorf("expected reason 'retry-on-failure', got %v", cap.env.Payload["reason"])
|
|
}
|
|
if cap.env.Payload["occurred_at"] != now.Format(time.RFC3339Nano) {
|
|
t.Errorf("expected occurred_at %q, got %v", now.Format(time.RFC3339Nano), cap.env.Payload["occurred_at"])
|
|
}
|
|
|
|
// Test omission of empty reason and zero occurred_at
|
|
err = sink.HandleTaskEvent(context.Background(), notification.TaskEvent{
|
|
Type: notification.TaskEventRunning,
|
|
TaskID: "task-8",
|
|
Title: "Run me again",
|
|
Status: "running",
|
|
Message: "started",
|
|
Attempt: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if _, ok := cap.env.Payload["reason"]; ok {
|
|
t.Error("expected empty reason to be omitted from payload, but it was present")
|
|
}
|
|
if _, ok := cap.env.Payload["occurred_at"]; ok {
|
|
t.Error("expected zero occurred_at to be omitted from payload, but it was present")
|
|
}
|
|
}
|
|
|
|
// --- route-level contract tests over the real router + proto-socket server ---
|
|
|
|
func newContractRouter(t *testing.T, fake TaskService) (*httptest.Server, func()) {
|
|
t.Helper()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv := NewServer(Config{HeartbeatIntervalSec: 0, HeartbeatWaitSec: 0}, logger)
|
|
NewTaskChannels(fake).Register(srv.Dispatcher())
|
|
|
|
router := apphttp.NewRouter(
|
|
apphttp.NewHandler(nil, nil, nil, logger),
|
|
logger,
|
|
apphttp.AuthConfig{Username: "nomadcode", Password: "secret"},
|
|
srv,
|
|
"/proto-socket",
|
|
)
|
|
ts := httptest.NewServer(router)
|
|
return ts, func() {
|
|
ts.Close()
|
|
_ = srv.Close()
|
|
}
|
|
}
|
|
|
|
func wsURL(ts *httptest.Server) string {
|
|
return strings.Replace(ts.URL, "http", "ws", 1) + "/proto-socket"
|
|
}
|
|
|
|
func authDialOptions() *websocket.DialOptions {
|
|
header := http.Header{}
|
|
header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("nomadcode:secret")))
|
|
return &websocket.DialOptions{HTTPHeader: header}
|
|
}
|
|
|
|
func TestRouterProtoSocketTaskListRequiresAuth(t *testing.T) {
|
|
fake := &fakeTaskService{listOut: []storage.Task{{ID: "task-1"}}}
|
|
ts, cleanup := newContractRouter(t, fake)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
// Unauthenticated dial must fail at the route.
|
|
if conn, _, err := websocket.Dial(ctx, wsURL(ts), nil); err == nil {
|
|
conn.Close(websocket.StatusNormalClosure, "")
|
|
t.Fatal("expected unauthenticated websocket dial to fail")
|
|
}
|
|
|
|
// Authenticated dial succeeds and task.list returns a response envelope.
|
|
conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
|
|
if err != nil {
|
|
t.Fatalf("authenticated dial failed: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
|
|
client := toki.NewWsClient(conn, 0, 0, ParserMap())
|
|
defer client.Close()
|
|
|
|
reqStruct, err := Envelope{
|
|
ProtocolVersion: ProtocolVersion,
|
|
ID: "req-list",
|
|
Type: "request",
|
|
Channel: "task",
|
|
Action: "task.list",
|
|
Payload: map[string]any{"limit": float64(20)},
|
|
}.ToStruct()
|
|
if err != nil {
|
|
t.Fatalf("failed to build request: %v", err)
|
|
}
|
|
|
|
resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("send request failed: %v", err)
|
|
}
|
|
res, err := EnvelopeFromStruct(resStruct)
|
|
if err != nil {
|
|
t.Fatalf("parse response failed: %v", err)
|
|
}
|
|
if res.Type != "response" || res.CorrelationID != "req-list" {
|
|
t.Fatalf("unexpected response: type=%q correlation=%q", res.Type, res.CorrelationID)
|
|
}
|
|
if _, ok := res.Payload["tasks"]; !ok {
|
|
t.Errorf("expected tasks payload, got %+v", res.Payload)
|
|
}
|
|
requireDiagnosticsMeta(t, res, "task", "task.list", "")
|
|
}
|
|
|
|
func TestProtoSocketTaskActionsMatchRestSemantics(t *testing.T) {
|
|
fake := &fakeTaskService{
|
|
createOut: storage.Task{ID: "task-c", Status: "pending"},
|
|
getOut: storage.Task{ID: "task-g", Status: "running"},
|
|
enqueueOut: storage.Task{ID: "task-e", Status: "queued"},
|
|
}
|
|
ts, cleanup := newContractRouter(t, fake)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
|
|
if err != nil {
|
|
t.Fatalf("dial failed: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
client := toki.NewWsClient(conn, 0, 0, ParserMap())
|
|
defer client.Close()
|
|
|
|
send := func(action string, payload map[string]any) Envelope {
|
|
reqStruct, err := Envelope{
|
|
ProtocolVersion: ProtocolVersion,
|
|
ID: "req-" + action,
|
|
Type: "request",
|
|
Channel: "task",
|
|
Action: action,
|
|
Payload: payload,
|
|
}.ToStruct()
|
|
if err != nil {
|
|
t.Fatalf("build request %s: %v", action, err)
|
|
}
|
|
resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("send %s: %v", action, err)
|
|
}
|
|
res, err := EnvelopeFromStruct(resStruct)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", action, err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
create := send("task.create", map[string]any{"title": "T", "source": "client", "payload": map[string]any{}})
|
|
if create.Type != "response" || create.Payload["id"] != "task-c" || create.Payload["status"] != "pending" {
|
|
t.Errorf("unexpected create response: %+v (err=%+v)", create.Payload, create.Error)
|
|
}
|
|
requireDiagnosticsMeta(t, create, "task", "task.create", "")
|
|
|
|
get := send("task.get", map[string]any{"id": "task-g"})
|
|
if get.Type != "response" {
|
|
t.Errorf("unexpected get response type: %q", get.Type)
|
|
}
|
|
if task, ok := get.Payload["task"].(map[string]any); !ok || task["status"] != "running" {
|
|
t.Errorf("unexpected get task payload: %+v", get.Payload)
|
|
}
|
|
requireDiagnosticsMeta(t, get, "task", "task.get", "")
|
|
|
|
enqueue := send("task.enqueue", map[string]any{"id": "task-e"})
|
|
if enqueue.Type != "response" || enqueue.Payload["status"] != "queued" {
|
|
t.Errorf("unexpected enqueue response: %+v", enqueue.Payload)
|
|
}
|
|
requireDiagnosticsMeta(t, enqueue, "task", "task.enqueue", "")
|
|
}
|
|
|
|
func TestProtoSocketTaskErrorEnvelopeCodes(t *testing.T) {
|
|
fake := &fakeTaskService{
|
|
getErr: pgx.ErrNoRows,
|
|
enqueueErr: workflow.ErrTaskCannotBeEnqueued,
|
|
createErr: workflow.ErrInvalidTaskInput,
|
|
}
|
|
ts, cleanup := newContractRouter(t, fake)
|
|
defer cleanup()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
conn, _, err := websocket.Dial(ctx, wsURL(ts), authDialOptions())
|
|
if err != nil {
|
|
t.Fatalf("dial failed: %v", err)
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
client := toki.NewWsClient(conn, 0, 0, ParserMap())
|
|
defer client.Close()
|
|
|
|
send := func(action string, payload map[string]any) Envelope {
|
|
reqStruct, _ := Envelope{ProtocolVersion: ProtocolVersion, ID: "e-" + action, Type: "request", Channel: "task", Action: action, Payload: payload}.ToStruct()
|
|
resStruct, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, reqStruct, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("send %s: %v", action, err)
|
|
}
|
|
res, _ := EnvelopeFromStruct(resStruct)
|
|
return res
|
|
}
|
|
|
|
cases := []struct {
|
|
action string
|
|
payload map[string]any
|
|
code string
|
|
}{
|
|
{"task.get", map[string]any{"id": "x"}, "task.not_found"},
|
|
{"task.enqueue", map[string]any{"id": "x"}, "task.conflict"},
|
|
{"task.create", map[string]any{"title": "T", "source": "client"}, "task.invalid_input"},
|
|
{"task.get", map[string]any{"id": float64(1)}, "task.invalid_payload"},
|
|
{"task.unknown", map[string]any{}, "UNSUPPORTED_ACTION"},
|
|
}
|
|
for _, tc := range cases {
|
|
res := send(tc.action, tc.payload)
|
|
if res.Type != "error" {
|
|
t.Errorf("%s: expected error type, got %q", tc.action, res.Type)
|
|
continue
|
|
}
|
|
if res.Error == nil || res.Error.Code != tc.code {
|
|
t.Errorf("%s: expected code %q, got %+v", tc.action, tc.code, res.Error)
|
|
}
|
|
requireDiagnosticsMeta(t, res, "task", tc.action, tc.code)
|
|
}
|
|
}
|
|
|
|
func TestProtoSocketServerUsesConfiguredHeartbeat(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv := NewServer(Config{HeartbeatIntervalSec: 1, HeartbeatWaitSec: 1}, logger)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv)
|
|
defer ts.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
conn, _, err := websocket.Dial(ctx, strings.Replace(ts.URL, "http", "ws", 1), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial failed: %v", err)
|
|
}
|
|
conn.Close(websocket.StatusNormalClosure, "")
|
|
}
|