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 _ *tangled.Pipeline_TriggerMetadata, 42 workflows []*tangled.Pipeline_Workflow, 43) { 44 s.mu.Lock() 45 defer s.mu.Unlock() 46 for _, wf := range workflows { 47 if wf == nil { 48 continue 49 } 50 s.spawnCalls = append(s.spawnCalls, wf.Name) 51 } 52} 53 54func (s *stubProvider) Logs( 55 _ context.Context, 56 _ string, 57 _ string, 58 _ string, 59) (<-chan LogLine, error) { 60 if s.logsErr != nil { 61 return nil, s.logsErr 62 } 63 if s.logsCh != nil { 64 return s.logsCh, nil 65 } 66 return nil, ErrLogsNotFound 67} 68 69// names returns a defensive copy of spawnCalls so the test can read 70// it without racing the router's per-workflow loop. The router calls 71// Spawn synchronously in the test process, but a copy is the safer 72// pattern if that ever changes. 73func (s *stubProvider) names() []string { 74 s.mu.Lock() 75 defer s.mu.Unlock() 76 out := make([]string, len(s.spawnCalls)) 77 copy(out, s.spawnCalls) 78 return out 79} 80 81// newRouterTest wires a router with a fixed pair of stubs ("a", "b") 82// so tests can focus on the YAML → provider mapping. The stubs are 83// returned alongside so each case can inspect what it received. 84func newRouterTest() (*providerRouter, *stubProvider, *stubProvider) { 85 a := &stubProvider{} 86 b := &stubProvider{} 87 r := newProviderRouter(slog.Default(), map[string]Provider{ 88 "a": a, 89 "b": b, 90 }) 91 return r, a, b 92} 93 94// TestProviderRouterSpawnRoutesByYAMLKey exercises the basic happy 95// path: each workflow's `tack:` block names exactly one provider key, 96// and the router hands that workflow to the matching provider only. 97func TestProviderRouterSpawnRoutesByYAMLKey(t *testing.T) { 98 r, a, b := newRouterTest() 99 100 r.Spawn(context.Background(), "knot", "rkey", nil, 101 []*tangled.Pipeline_Workflow{ 102 {Name: "wf-a.yml", Raw: "tack:\n a: {}\n"}, 103 {Name: "wf-b.yml", Raw: "tack:\n b: {}\n"}, 104 }, 105 ) 106 107 if got, want := a.names(), []string{"wf-a.yml"}; !equalStrings(got, want) { 108 t.Fatalf("provider a got %v; want %v", got, want) 109 } 110 if got, want := b.names(), []string{"wf-b.yml"}; !equalStrings(got, want) { 111 t.Fatalf("provider b got %v; want %v", got, want) 112 } 113} 114 115// TestProviderRouterSpawnFirstYAMLKeyWins pins tie-breaking when a 116// workflow lists multiple provider keys: the YAML's document-order 117// child of `tack:` wins, regardless of the Go map's iteration order. 118func TestProviderRouterSpawnFirstYAMLKeyWins(t *testing.T) { 119 r, a, b := newRouterTest() 120 121 // `b` is listed first under `tack:` so it should claim the 122 // workflow even though both keys are registered. 123 r.Spawn(context.Background(), "knot", "rkey", nil, 124 []*tangled.Pipeline_Workflow{ 125 {Name: "both.yml", Raw: "tack:\n b: {}\n a: {}\n"}, 126 }, 127 ) 128 129 if got := a.names(); len(got) != 0 { 130 t.Fatalf("provider a should not have been called; got %v", got) 131 } 132 if got, want := b.names(), []string{"both.yml"}; !equalStrings(got, want) { 133 t.Fatalf("provider b got %v; want %v", got, want) 134 } 135} 136 137// TestProviderRouterSpawnSkipsUnroutable confirms that workflows 138// whose YAML has no matching provider key are skipped (logged but 139// not dispatched) and don't poison the rest of the batch. 140func TestProviderRouterSpawnSkipsUnroutable(t *testing.T) { 141 r, a, b := newRouterTest() 142 143 r.Spawn(context.Background(), "knot", "rkey", nil, 144 []*tangled.Pipeline_Workflow{ 145 // No `tack:` key at all. 146 {Name: "bare.yml", Raw: "steps: []\n"}, 147 // `tack:` present but with an unknown sub-key. 148 {Name: "unknown.yml", Raw: "tack:\n nope: {}\n"}, 149 // Empty body — also unroutable. 150 {Name: "empty.yml", Raw: ""}, 151 // And one good one to prove the loop kept going. 152 {Name: "good.yml", Raw: "tack:\n a: {}\n"}, 153 }, 154 ) 155 156 if got, want := a.names(), []string{"good.yml"}; !equalStrings(got, want) { 157 t.Fatalf("provider a got %v; want %v", got, want) 158 } 159 if got := b.names(); len(got) != 0 { 160 t.Fatalf("provider b should not have been called; got %v", got) 161 } 162} 163 164// TestProviderRouterLogsFanOut verifies that Logs walks the 165// providers and returns the channel from the first one that doesn't 166// say ErrLogsNotFound. We seed exactly one provider with a real 167// channel; map iteration order is unspecified but only one provider 168// can possibly answer, so the test is deterministic. 169func TestProviderRouterLogsFanOut(t *testing.T) { 170 r, _, b := newRouterTest() 171 172 want := make(chan LogLine) 173 b.logsCh = want 174 175 got, err := r.Logs(context.Background(), "k", "p", "w") 176 if err != nil { 177 t.Fatalf("Logs: %v", err) 178 } 179 if got != (<-chan LogLine)(want) { 180 t.Fatalf("got channel %v; want %v", got, want) 181 } 182} 183 184// TestProviderRouterLogsAllNotFound makes sure that when no provider 185// claims the tuple, the router surfaces ErrLogsNotFound itself — 186// this is what the HTTP handler maps to a 404. 187func TestProviderRouterLogsAllNotFound(t *testing.T) { 188 r, _, _ := newRouterTest() 189 190 ch, err := r.Logs(context.Background(), "k", "p", "w") 191 if !errors.Is(err, ErrLogsNotFound) { 192 t.Fatalf("err = %v; want ErrLogsNotFound", err) 193 } 194 if ch != nil { 195 t.Fatalf("channel should be nil on not-found") 196 } 197} 198 199// TestProviderRouterLogsBackendError confirms that a non-NotFound 200// error from a provider is returned verbatim instead of being 201// masked by the fan-out — backend failures must reach the HTTP 202// caller as 5xx, not be silently retried elsewhere. 203func TestProviderRouterLogsBackendError(t *testing.T) { 204 a := &stubProvider{logsErr: errors.New("boom")} 205 r := newProviderRouter(slog.Default(), map[string]Provider{"a": a}) 206 207 ch, err := r.Logs(context.Background(), "k", "p", "w") 208 if err == nil || err.Error() != "boom" { 209 t.Fatalf("err = %v; want boom", err) 210 } 211 if ch != nil { 212 t.Fatalf("channel should be nil on backend error") 213 } 214} 215 216// equalStrings is a small helper to compare ordered string slices — 217// the router preserves workflow order within Spawn, so the tests 218// assert against ordered slices rather than sets. 219func equalStrings(a, b []string) bool { 220 if len(a) != len(b) { 221 return false 222 } 223 for i := range a { 224 if a[i] != b[i] { 225 return false 226 } 227 } 228 return true 229}