Files
orca/internal/daemon/dispatch_test.go
T
Jon Chery df58bc25a3 docs(milestone): complete scheduling-streaming (v0.3)
---ci---
project: orca
phase: 3
milestone: v0.3
status: complete
requirements:
  covered: [REQ-022, REQ-030, REQ-032]
  partial: []
---/ci---

v0.3 milestone merged to main. Includes all v0.2 work (P08-P10) that
was previously on the milestone branch but not yet merged to main, plus
the v0.3 completion work (iter.Seq streaming + doctor network/db).

v0.2 phases included: P08 (mTLS), P09 (scheduling), P10 (security scan).
v0.3 phases: P0 (pre-execution), P1 (iter.Seq streaming), P2 (doctor),
P3 (final review+ship).

Total: 40 requirements, all complete. No new go.mod dependencies.
Full test suite passes under -race. gofmt + go vet clean.
2026-08-01 20:06:47 +00:00

179 lines
5.3 KiB
Go

// Package daemon — dispatch_test.go exercises the orca.v1.Dispatch
// round-trip end-to-end: a SubmitHandler is mounted on a test server
// and a DispatchClient dials it. The test asserts the spec flows
// through, the job ID is returned, and dedupe (X-Orca-Idempotency-Key)
// works.
package daemon
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"git.cloudinit.dev/coreci/orca/internal/transport"
)
// stubDispatcher is a transport.Dispatcher for tests. It records
// every Submit and Status call and returns deterministic responses.
type stubDispatcher struct {
mu sync.Mutex
submits [][]byte
statuses []string
nextJobID int
failSubmit bool
}
func (s *stubDispatcher) LocalSubmit(_ context.Context, spec []byte) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.failSubmit {
return "", fmt.Errorf("submit failed (test)")
}
cp := make([]byte, len(spec))
copy(cp, spec)
s.submits = append(s.submits, cp)
s.nextJobID++
return fmt.Sprintf("job-%d", s.nextJobID), nil
}
func (s *stubDispatcher) LocalStatus(_ context.Context, jobID string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.statuses = append(s.statuses, jobID)
return "running", nil
}
func TestDispatchRoundTrip(t *testing.T) {
stub := &stubDispatcher{}
dedupe := transport.NewIdempotencyStore()
handlers := NewDispatchHandlers(stub, dedupe)
mux := http.NewServeMux()
handlers.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
// Submit a spec wrapped in the SubmitRequest envelope.
// The wire format is {"spec": <json.RawMessage>}; the inner
// spec is opaque to the dispatch service and is parsed by the
// local executor downstream.
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"],"env":[]}`)
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
resp, err := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader(wire))
if err != nil {
t.Fatalf("Submit: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Submit status: got %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var sr transport.SubmitResponse
if err := json.Unmarshal(body, &sr); err != nil {
t.Fatalf("decode Submit response: %v", err)
}
if sr.JobID == "" {
t.Fatal("Submit response missing job_id")
}
if len(stub.submits) != 1 {
t.Errorf("LocalSubmit calls: got %d, want 1", len(stub.submits))
}
// Status query.
statusReq := transport.StatusRequest{JobID: sr.JobID}
body2, _ := json.Marshal(statusReq)
resp2, err := http.Post(ts.URL+"/orca.v1.Dispatch/Status", "application/json", bytes.NewReader(body2))
if err != nil {
t.Fatalf("Status: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("Status code: got %d, want 200", resp2.StatusCode)
}
var stResp transport.StatusResponse
if err := json.NewDecoder(resp2.Body).Decode(&stResp); err != nil {
t.Fatalf("decode Status: %v", err)
}
if stResp.State != "running" {
t.Errorf("Status.State: got %q, want running", stResp.State)
}
}
func TestDispatchIdempotencyDedupe(t *testing.T) {
stub := &stubDispatcher{}
dedupe := transport.NewIdempotencyStore()
handlers := NewDispatchHandlers(stub, dedupe)
mux := http.NewServeMux()
handlers.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"]}`)
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
post := func() string {
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/orca.v1.Dispatch/Submit", bytes.NewReader(wire))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(transport.IdempotencyHeader, "key-42")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Submit: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// First call: real submit, LocalSubmit invoked.
first := post()
var sr1 transport.SubmitResponse
if err := json.Unmarshal([]byte(first), &sr1); err != nil {
t.Fatalf("decode 1: %v", err)
}
if len(stub.submits) != 1 {
t.Errorf("after first call: submits=%d, want 1", len(stub.submits))
}
// Second call: same key, dedupe replay.
second := post()
var sr2 transport.SubmitResponse
if err := json.Unmarshal([]byte(second), &sr2); err != nil {
t.Fatalf("decode 2: %v", err)
}
if sr1.JobID != sr2.JobID {
t.Errorf("dedupe: first=%s, second=%s (should match)", sr1.JobID, sr2.JobID)
}
if len(stub.submits) != 1 {
t.Errorf("after second call: submits=%d, want 1 (dedupe)", len(stub.submits))
}
}
func TestDispatchSubmitValidation(t *testing.T) {
stub := &stubDispatcher{}
handlers := NewDispatchHandlers(stub, transport.NewIdempotencyStore())
mux := http.NewServeMux()
handlers.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
// Empty spec: 400.
resp, _ := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader([]byte(`{}`)))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("empty spec: status=%d, want 400", resp.StatusCode)
}
resp.Body.Close()
// GET instead of POST: 405.
resp2, _ := http.Get(ts.URL + "/orca.v1.Dispatch/Submit")
if resp2.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET: status=%d, want 405", resp2.StatusCode)
}
resp2.Body.Close()
}