Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

1package main 2 3// Tests for providerRouter. We use a tiny in-test stub Provider — 4// stubProvider — that records every Spawn call and serves canned 5// Logs responses, so the tests stay focused on routing behaviour 6// (which provider got called for which workflow YAML, and how 7// ErrLogsNotFound is fanned out) without dragging in either the 8// fake or Buildkite providers' end-to-end machinery. 9 10import ( 11 "context" 12 "errors" 13 "log/slog" 14 "sync" 15 "testing" 16 17 "tangled.org/core/api/tangled" 18) 19 20// stubProvider is a minimal Provider for routing tests. spawnCalls 21// captures the workflow names handed to Spawn (single-element slices 22// per the router's contract) so a test can assert which provider got 23// which workflow. logsErr / logsCh govern what Logs returns; the 24// default (zero value) is ErrLogsNotFound + nil channel, which makes 25// fan-out tests easy to express by overriding only the provider that 26// should "claim" the request. 27type stubProvider struct { 28 mu sync.Mutex 29 spawnCalls []string 30 31 logsErr error 32 logsCh chan LogLine 33} 34 35var _ Provider = (*stubProvider)(nil) 36 37func (s *stubProvider) Spawn( 38 _ context.Context, 39 _ string, 40 _ string, 41 _ string, 42 _ *tangled.Pipeline_TriggerMetadata, 43 workflows []*tangled.Pipeline_Workflow, 44) { 45 s.mu.Lock() 46 defer s.mu.Unlock() 47 for _, wf := range workflows { 48 if wf == nil { 49 continue 50 } 51 s.spawnCalls = append(s.spawnCalls, wf.Name) 52 } 53} 54 55func (s *stubProvider) Logs( 56 _ context.Context, 57 _ string, 58 _ string, 59 _ string, 60) (<-chan LogLine, error) { 61 if s.logsErr != nil { 62 return nil, s.logsErr 63 } 64 if s.logsCh != nil { 65 return s.logsCh, nil 66 } 67 return nil, ErrLogsNotFound 68} 69 70// names returns a defensive copy of spawnCalls so the test can read 71// it without racing the router's per-workflow loop. The router calls 72// Spawn synchronously in the test process, but a copy is the safer 73// pattern if that ever changes. 74func (s *stubProvider) names() []string { 75 s.mu.Lock() 76 defer s.mu.Unlock() 77 out := make([]string, len(s.spawnCalls)) 78 copy(out, s.spawnCalls) 79 return out 80} 81 82// newRouterTest wires a router with a fixed pair of stubs ("a", "b") 83// so tests can focus on the YAML → provider mapping. The stubs are 84// returned alongside so each case can inspect what it received. 85func newRouterTest() (*providerRouter, *stubProvider, *stubProvider) { 86 a := &stubProvider{} 87 b := &stubProvider{} 88 r := newProviderRouter(slog.Default(), map[string]Provider{ 89 "a": a, 90 "b": b, 91 }) 92 return r, a, b 93} 94 95// TestProviderRouterSpawnRoutesByYAMLKey exercises the basic happy 96// path: each workflow's `tack:` block names exactly one provider key, 97// and the router hands that workflow to the matching provider only. 98func TestProviderRouterSpawnRoutesByYAMLKey(t *testing.T) { 99 r, a, b := newRouterTest() 100 101 r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 102 []*tangled.Pipeline_Workflow{ 103 {Name: "wf-a.yml", Engine: "tack", Raw: "tack:\n a: {}\n"}, 104 {Name: "wf-b.yml", Engine: "tack", Raw: "tack:\n b: {}\n"}, 105 }, 106 ) 107 108 if got, want := a.names(), []string{"wf-a.yml"}; !equalStrings(got, want) { 109 t.Fatalf("provider a got %v; want %v", got, want) 110 } 111 if got, want := b.names(), []string{"wf-b.yml"}; !equalStrings(got, want) { 112 t.Fatalf("provider b got %v; want %v", got, want) 113 } 114} 115 116// TestProviderRouterSpawnFirstYAMLKeyWins pins tie-breaking when a 117// workflow lists multiple provider keys: the YAML's document-order 118// child of `tack:` wins, regardless of the Go map's iteration order. 119func TestProviderRouterSpawnFirstYAMLKeyWins(t *testing.T) { 120 r, a, b := newRouterTest() 121 122 // `b` is listed first under `tack:` so it should claim the 123 // workflow even though both keys are registered. 124 r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 125 []*tangled.Pipeline_Workflow{ 126 {Name: "both.yml", Engine: "tack", Raw: "tack:\n b: {}\n a: {}\n"}, 127 }, 128 ) 129 130 if got := a.names(); len(got) != 0 { 131 t.Fatalf("provider a should not have been called; got %v", got) 132 } 133 if got, want := b.names(), []string{"both.yml"}; !equalStrings(got, want) { 134 t.Fatalf("provider b got %v; want %v", got, want) 135 } 136} 137 138// TestProviderRouterSpawnSkipsUnroutable confirms that workflows 139// whose YAML has no matching provider key are skipped (logged but 140// not dispatched) and don't poison the rest of the batch. 141func TestProviderRouterSpawnSkipsUnroutable(t *testing.T) { 142 r, a, b := newRouterTest() 143 144 r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 145 []*tangled.Pipeline_Workflow{ 146 // No `tack:` key at all. 147 {Name: "bare.yml", Engine: "tack", Raw: "steps: []\n"}, 148 // `tack:` present but with an unknown sub-key. 149 {Name: "unknown.yml", Engine: "tack", Raw: "tack:\n nope: {}\n"}, 150 // Empty body — also unroutable. 151 {Name: "empty.yml", Engine: "tack", Raw: ""}, 152 // And one good one to prove the loop kept going. 153 {Name: "good.yml", Engine: "tack", Raw: "tack:\n a: {}\n"}, 154 }, 155 ) 156 157 if got, want := a.names(), []string{"good.yml"}; !equalStrings(got, want) { 158 t.Fatalf("provider a got %v; want %v", got, want) 159 } 160 if got := b.names(); len(got) != 0 { 161 t.Fatalf("provider b should not have been called; got %v", got) 162 } 163} 164 165// TestProviderRouterSpawnRejectsWrongEngine verifies that workflows 166// without `engine: tack` are skipped. 167func TestProviderRouterSpawnRejectsWrongEngine(t *testing.T) { 168 r, a, b := newRouterTest() 169 170 r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 171 []*tangled.Pipeline_Workflow{ 172 {Name: "wrong-engine.yml", Engine: "nixery", Raw: "tack:\n a: {}\n"}, 173 {Name: "empty-engine.yml", Engine: "", Raw: "tack:\n a: {}\n"}, 174 {Name: "correct.yml", Engine: "tack", Raw: "tack:\n b: {}\n"}, 175 }, 176 ) 177 178 // Only "correct.yml" should reach a provider 179 if got := a.names(); len(got) != 0 { 180 t.Fatalf("provider a should not have been called; got %v", got) 181 } 182 if got, want := b.names(), []string{"correct.yml"}; !equalStrings(got, want) { 183 t.Fatalf("provider b got %v; want %v", got, want) 184 } 185} 186 187// TestProviderRouterLogsFanOut verifies that Logs walks the 188// providers and returns the channel from the first one that doesn't 189// say ErrLogsNotFound. We seed exactly one provider with a real 190// channel; map iteration order is unspecified but only one provider 191// can possibly answer, so the test is deterministic. 192func TestProviderRouterLogsFanOut(t *testing.T) { 193 r, _, b := newRouterTest() 194 195 want := make(chan LogLine) 196 b.logsCh = want 197 198 got, err := r.Logs(context.Background(), "k", "p", "w") 199 if err != nil { 200 t.Fatalf("Logs: %v", err) 201 } 202 if got != (<-chan LogLine)(want) { 203 t.Fatalf("got channel %v; want %v", got, want) 204 } 205} 206 207// TestProviderRouterLogsAllNotFound makes sure that when no provider 208// claims the tuple, the router surfaces ErrLogsNotFound itself — 209// this is what the HTTP handler maps to a 404. 210func TestProviderRouterLogsAllNotFound(t *testing.T) { 211 r, _, _ := newRouterTest() 212 213 ch, err := r.Logs(context.Background(), "k", "p", "w") 214 if !errors.Is(err, ErrLogsNotFound) { 215 t.Fatalf("err = %v; want ErrLogsNotFound", err) 216 } 217 if ch != nil { 218 t.Fatalf("channel should be nil on not-found") 219 } 220} 221 222// TestProviderRouterLogsBackendError confirms that a non-NotFound 223// error from a provider is returned verbatim instead of being 224// masked by the fan-out — backend failures must reach the HTTP 225// caller as 5xx, not be silently retried elsewhere. 226func TestProviderRouterLogsBackendError(t *testing.T) { 227 a := &stubProvider{logsErr: errors.New("boom")} 228 r := newProviderRouter(slog.Default(), map[string]Provider{"a": a}) 229 230 ch, err := r.Logs(context.Background(), "k", "p", "w") 231 if err == nil || err.Error() != "boom" { 232 t.Fatalf("err = %v; want boom", err) 233 } 234 if ch != nil { 235 t.Fatalf("channel should be nil on backend error") 236 } 237} 238 239// equalStrings is a small helper to compare ordered string slices — 240// the router preserves workflow order within Spawn, so the tests 241// assert against ordered slices rather than sets. 242func equalStrings(a, b []string) bool { 243 if len(a) != len(b) { 244 return false 245 } 246 for i := range a { 247 if a[i] != b[i] { 248 return false 249 } 250 } 251 return true 252}