Stitch any CI into Tangled
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}