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 _ 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}