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", 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}