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", Raw: "tack:\n a: {}\n"}, 104 {Name: "wf-b.yml", 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", 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", Raw: "steps: []\n"}, 148 // `tack:` present but with an unknown sub-key. 149 {Name: "unknown.yml", Raw: "tack:\n nope: {}\n"}, 150 // Empty body — also unroutable. 151 {Name: "empty.yml", Raw: ""}, 152 // And one good one to prove the loop kept going. 153 {Name: "good.yml", 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// TestProviderRouterLogsFanOut verifies that Logs walks the 166// providers and returns the channel from the first one that doesn't 167// say ErrLogsNotFound. We seed exactly one provider with a real 168// channel; map iteration order is unspecified but only one provider 169// can possibly answer, so the test is deterministic. 170func TestProviderRouterLogsFanOut(t *testing.T) { 171 r, _, b := newRouterTest() 172 173 want := make(chan LogLine) 174 b.logsCh = want 175 176 got, err := r.Logs(context.Background(), "k", "p", "w") 177 if err != nil { 178 t.Fatalf("Logs: %v", err) 179 } 180 if got != (<-chan LogLine)(want) { 181 t.Fatalf("got channel %v; want %v", got, want) 182 } 183} 184 185// TestProviderRouterLogsAllNotFound makes sure that when no provider 186// claims the tuple, the router surfaces ErrLogsNotFound itself — 187// this is what the HTTP handler maps to a 404. 188func TestProviderRouterLogsAllNotFound(t *testing.T) { 189 r, _, _ := newRouterTest() 190 191 ch, err := r.Logs(context.Background(), "k", "p", "w") 192 if !errors.Is(err, ErrLogsNotFound) { 193 t.Fatalf("err = %v; want ErrLogsNotFound", err) 194 } 195 if ch != nil { 196 t.Fatalf("channel should be nil on not-found") 197 } 198} 199 200// TestProviderRouterLogsBackendError confirms that a non-NotFound 201// error from a provider is returned verbatim instead of being 202// masked by the fan-out — backend failures must reach the HTTP 203// caller as 5xx, not be silently retried elsewhere. 204func TestProviderRouterLogsBackendError(t *testing.T) { 205 a := &stubProvider{logsErr: errors.New("boom")} 206 r := newProviderRouter(slog.Default(), map[string]Provider{"a": a}) 207 208 ch, err := r.Logs(context.Background(), "k", "p", "w") 209 if err == nil || err.Error() != "boom" { 210 t.Fatalf("err = %v; want boom", err) 211 } 212 if ch != nil { 213 t.Fatalf("channel should be nil on backend error") 214 } 215} 216 217// equalStrings is a small helper to compare ordered string slices — 218// the router preserves workflow order within Spawn, so the tests 219// assert against ordered slices rather than sets. 220func equalStrings(a, b []string) bool { 221 if len(a) != len(b) { 222 return false 223 } 224 for i := range a { 225 if a[i] != b[i] { 226 return false 227 } 228 } 229 return true 230}