// 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": }; 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() }