package engine import ( "context" "errors" "path/filepath" "testing" "git.cloudinit.dev/coreci/orca/internal/store" ) type mockExecutor struct { submitFn func(ctx context.Context, spec []byte) (string, error) statusFn func(ctx context.Context, jobID string) (string, error) submitted bool } func (m *mockExecutor) Submit(ctx context.Context, spec []byte) (string, error) { m.submitted = true if m.submitFn != nil { return m.submitFn(ctx, spec) } return "mock-job-id", nil } func (m *mockExecutor) Status(ctx context.Context, jobID string) (string, error) { if m.statusFn != nil { return m.statusFn(ctx, jobID) } return "complete", nil } func newTestDispatcher(t *testing.T, exec LocalExecutor) (*Dispatcher, *store.CapacityRepo, func()) { t.Helper() path := filepath.Join(t.TempDir(), "test.db") db, err := store.Open(path) if err != nil { t.Fatalf("open db: %v", err) } capRepo := store.NewCapacityRepo(db) peers := NewPeerRegistry() d := NewDispatcher(nil, capRepo, peers, exec) return d, capRepo, func() { _ = db.Close() } } func TestDispatcher_Submit_EmptySpec(t *testing.T) { d, _, cleanup := newTestDispatcher(t, &mockExecutor{}) defer cleanup() _, _, err := d.Submit(context.Background(), "", nil, "") if err == nil { t.Fatal("Submit: expected error for empty spec, got nil") } } func TestDispatcher_Submit_IdempotencyHit(t *testing.T) { exec := &mockExecutor{} d, _, cleanup := newTestDispatcher(t, exec) defer cleanup() d.Dedupe().Put("key-1", "cached-job-id") spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`) jobID, nodeID, err := d.Submit(context.Background(), "", spec, "key-1") if err != nil { t.Fatalf("Submit: %v", err) } if jobID != "cached-job-id" { t.Errorf("jobID: got %q, want cached-job-id", jobID) } if nodeID != "self" { t.Errorf("nodeID: got %q, want self", nodeID) } if exec.submitted { t.Error("executor was called on idempotency hit; should have been short-circuited") } } func TestDispatcher_Submit_LocalCapacity(t *testing.T) { exec := &mockExecutor{ submitFn: func(ctx context.Context, spec []byte) (string, error) { return "local-job-id", nil }, } d, capRepo, cleanup := newTestDispatcher(t, exec) defer cleanup() ctx := context.Background() if err := capRepo.Upsert(ctx, &store.NodeCapacity{ NodeID: "self", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096, }); err != nil { t.Fatalf("Upsert capacity: %v", err) } spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`) jobID, nodeID, err := d.Submit(ctx, "", spec, "") if err != nil { t.Fatalf("Submit: %v", err) } if jobID != "local-job-id" { t.Errorf("jobID: got %q, want local-job-id", jobID) } if nodeID != "self" { t.Errorf("nodeID: got %q, want self", nodeID) } if !exec.submitted { t.Error("executor was not called for local-capacity path") } } func TestDispatcher_Submit_ExplicitTarget(t *testing.T) { exec := &mockExecutor{} d, _, cleanup := newTestDispatcher(t, exec) defer cleanup() spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`) _, _, err := d.Submit(context.Background(), "nodeA", spec, "") if err == nil { t.Fatal("Submit with explicit target nodeA (no peer): expected error, got nil") } } func TestDispatcher_Submit_NoPeers(t *testing.T) { exec := &mockExecutor{} d, capRepo, cleanup := newTestDispatcher(t, exec) defer cleanup() ctx := context.Background() if err := capRepo.Upsert(ctx, &store.NodeCapacity{ NodeID: "self", CPUMillicores: 0, MemoryMiB: 0, DiskMiB: 0, }); err != nil { t.Fatalf("Upsert: %v", err) } spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`) _, _, err := d.Submit(ctx, "", spec, "") if err == nil { t.Fatal("Submit: expected error when no peers and no local capacity, got nil") } } func TestDispatcher_LocalSubmit(t *testing.T) { exec := &mockExecutor{ submitFn: func(ctx context.Context, spec []byte) (string, error) { return "ls-job", nil }, } d, _, cleanup := newTestDispatcher(t, exec) defer cleanup() jobID, err := d.LocalSubmit(context.Background(), []byte(`{"command":"/bin/true"}`)) if err != nil { t.Fatalf("LocalSubmit: %v", err) } if jobID != "ls-job" { t.Errorf("LocalSubmit: got %q, want ls-job", jobID) } if !exec.submitted { t.Error("LocalSubmit: executor.Submit not called") } } func TestDispatcher_LocalStatus(t *testing.T) { exec := &mockExecutor{ statusFn: func(ctx context.Context, jobID string) (string, error) { if jobID == "known" { return "running", nil } return "", errors.New("not found") }, } d, _, cleanup := newTestDispatcher(t, exec) defer cleanup() st, err := d.LocalStatus(context.Background(), "known") if err != nil { t.Fatalf("LocalStatus: %v", err) } if st != "running" { t.Errorf("LocalStatus: got %q, want running", st) } if _, err := d.LocalStatus(context.Background(), "missing"); err == nil { t.Error("LocalStatus: expected error for missing job, got nil") } } func TestDispatcher_LocalSubmit_NilExecutor(t *testing.T) { d := NewDispatcher(nil, nil, NewPeerRegistry(), nil) if _, err := d.LocalSubmit(context.Background(), []byte(`{}`)); err == nil { t.Error("LocalSubmit with nil executor: expected error, got nil") } if _, err := d.LocalStatus(context.Background(), "x"); err == nil { t.Error("LocalStatus with nil executor: expected error, got nil") } } func TestParseInlineSpec(t *testing.T) { spec, err := parseInlineSpec([]byte(`{"cpu_millicores":500,"memory_mib":256,"disk_mib":128}`)) if err != nil { t.Fatalf("parseInlineSpec: %v", err) } if spec.CPUMillicores != 500 || spec.MemoryMiB != 256 || spec.DiskMiB != 128 { t.Errorf("parseInlineSpec: got %+v, want cpu=500 mem=256 disk=128", spec) } if _, err := parseInlineSpec([]byte(`{bad json`)); err == nil { t.Fatal("parseInlineSpec: expected error for malformed JSON, got nil") } } func TestDispatcher_Submit_BadSpec(t *testing.T) { d, _, cleanup := newTestDispatcher(t, &mockExecutor{}) defer cleanup() _, _, err := d.Submit(context.Background(), "", []byte(`{bad json`), "") if err == nil { t.Fatal("expected error for malformed spec") } } func TestDispatcher_Submit_ExplicitTargetNoPeerRegistry(t *testing.T) { d := NewDispatcher(nil, nil, nil, &mockExecutor{}) _, _, err := d.Submit(context.Background(), "nodeX", []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`), "") if err == nil { t.Fatal("expected error for explicit target with no peer registry") } } func TestDispatcher_Submit_ExplicitTargetPeerNotFound(t *testing.T) { d, _, cleanup := newTestDispatcher(t, &mockExecutor{}) defer cleanup() _, _, err := d.Submit(context.Background(), "ghost", []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`), "") if err == nil { t.Fatal("expected error for target not in registry") } } func TestDispatcher_Submit_PickPeerMissingCA(t *testing.T) { exec := &mockExecutor{} d, capRepo, cleanup := newTestDispatcher(t, exec) defer cleanup() ctx := context.Background() if err := capRepo.Upsert(ctx, &store.NodeCapacity{ NodeID: "self", CPUMillicores: 0, MemoryMiB: 0, DiskMiB: 0, }); err != nil { t.Fatalf("Upsert: %v", err) } if err := d.peers.Add(&Peer{ NodeID: "peer-1", Address: "127.0.0.1:1", Capacity: &store.NodeCapacity{NodeID: "peer-1", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096}, }); err != nil { t.Fatalf("Add peer: %v", err) } spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`) _, _, err := d.Submit(ctx, "", spec, "idem-peer-1") if err == nil { t.Fatal("expected error (peer missing CA/servername)") } } func TestDispatcher_Submit_NoPeerRegistry(t *testing.T) { d := NewDispatcher(nil, nil, nil, &mockExecutor{}) spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`) _, _, err := d.Submit(context.Background(), "", spec, "") if err == nil { t.Fatal("expected error for no peer registry and no capacity repo") } } func TestDispatcher_Submit_NilCapacityFallsThrough(t *testing.T) { exec := &mockExecutor{} d := NewDispatcher(nil, nil, NewPeerRegistry(), exec) spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`) _, _, err := d.Submit(context.Background(), "", spec, "") if err == nil { t.Fatal("expected error when capacity repo is nil and no peers") } } func TestDispatcher_Submit_AllPeersFailsPickNode(t *testing.T) { exec := &mockExecutor{} d, capRepo, cleanup := newTestDispatcher(t, exec) defer cleanup() ctx := context.Background() if err := capRepo.Upsert(ctx, &store.NodeCapacity{ NodeID: "self", CPUMillicores: 0, MemoryMiB: 0, DiskMiB: 0, }); err != nil { t.Fatalf("Upsert: %v", err) } if err := d.peers.Add(&Peer{ NodeID: "peer-tiny", Address: "127.0.0.1:1", Capacity: &store.NodeCapacity{NodeID: "peer-tiny", CPUMillicores: 10, MemoryMiB: 10, DiskMiB: 10}, }); err != nil { t.Fatalf("Add peer: %v", err) } spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`) _, _, err := d.Submit(ctx, "", spec, "") if err == nil { t.Fatal("expected error when no peer can fit") } }