master
go 1,249 lines 35.1 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package dyncfg
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/netdata/netdata/go/plugins/logger"
15 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
16 "github.com/netdata/netdata/go/plugins/pkg/safewriter"
17 "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
18
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21 )
22
23 // codedErr implements CodedError for testing.
24 type codedErr struct {
25 err error
26 code int
27 }
28
29 func (e *codedErr) Error() string { return e.err.Error() }
30 func (e *codedErr) Code() int { return e.code }
31
32 // mockCallbacks records all callback invocations for verification.
33 type mockCallbacks struct {
34 extractKeyFn func(fn Function) (string, string, bool)
35 parseAndValidateFn func(fn Function, name string) (testConfig, error)
36 startFn func(cfg testConfig) error
37 updateFn func(oldCfg, newCfg testConfig) error
38 stopFn func(cfg testConfig)
39 onStatusChangeFn func(entry *Entry[testConfig], oldStatus Status, fn Function)
40 configIDFn func(cfg testConfig) string
41
42 startCalls []testConfig
43 updateCalls []updateCall
44 stopCalls []testConfig
45 statusCalls []statusChangeCall
46 }
47
48 type updateCall struct {
49 oldCfg, newCfg testConfig
50 }
51
52 type statusChangeCall struct {
53 entry *Entry[testConfig]
54 oldStatus Status
55 }
56
57 func (m *mockCallbacks) ExtractKey(fn Function) (string, string, bool) {
58 if m.extractKeyFn != nil {
59 return m.extractKeyFn(fn)
60 }
61 // Default: extract key from ID like "prefix:name".
62 parts := strings.SplitN(fn.ID(), ":", 2)
63 if len(parts) != 2 || parts[1] == "" {
64 return "", "", false
65 }
66 return parts[1], parts[1], true
67 }
68
69 func (m *mockCallbacks) ParseAndValidate(fn Function, name string) (testConfig, error) {
70 if m.parseAndValidateFn != nil {
71 return m.parseAndValidateFn(fn, name)
72 }
73 return testConfig{uid: "dyncfg:" + name, key: name, sourceType: "dyncfg", source: "test"}, nil
74 }
75
76 func (m *mockCallbacks) ValidateJobName(name string) error {
77 return JobNameRuleStrict(name)
78 }
79
80 func (m *mockCallbacks) Start(cfg testConfig) error {
81 m.startCalls = append(m.startCalls, cfg)
82 if m.startFn != nil {
83 return m.startFn(cfg)
84 }
85 return nil
86 }
87
88 func (m *mockCallbacks) Update(oldCfg, newCfg testConfig) error {
89 m.updateCalls = append(m.updateCalls, updateCall{oldCfg, newCfg})
90 if m.updateFn != nil {
91 return m.updateFn(oldCfg, newCfg)
92 }
93 return nil
94 }
95
96 func (m *mockCallbacks) Stop(cfg testConfig) {
97 m.stopCalls = append(m.stopCalls, cfg)
98 if m.stopFn != nil {
99 m.stopFn(cfg)
100 }
101 }
102
103 func (m *mockCallbacks) OnStatusChange(entry *Entry[testConfig], oldStatus Status, fn Function) {
104 m.statusCalls = append(m.statusCalls, statusChangeCall{entry: entry, oldStatus: oldStatus})
105 if m.onStatusChangeFn != nil {
106 m.onStatusChangeFn(entry, oldStatus, fn)
107 }
108 }
109
110 func (m *mockCallbacks) ConfigID(cfg testConfig) string {
111 if m.configIDFn != nil {
112 return m.configIDFn(cfg)
113 }
114 return "test:" + cfg.ExposedKey()
115 }
116
117 func newTestHandler(cb *mockCallbacks) *Handler[testConfig] {
118 return newTestHandlerWithWaitTimeout(cb, 5*time.Second)
119 }
120
121 func newTestHandlerWithWaitTimeout(cb *mockCallbacks, waitTimeout time.Duration) *Handler[testConfig] {
122 var buf bytes.Buffer
123 api := NewResponder(netdataapi.New(safewriter.New(&buf)))
124 return NewHandler(HandlerOpts[testConfig]{
125 Logger: logger.New(),
126 API: api,
127 Seen: NewSeenCache[testConfig](),
128 Exposed: NewExposedCache[testConfig](),
129 Callbacks: cb,
130 WaitKey: func(cfg testConfig) string {
131 return cfg.Source()
132 },
133 WaitTimeout: waitTimeout,
134
135 Path: "/test/path",
136 EnableFailCode: 200,
137 RemoveStockOnEnableFail: true,
138 JobCommands: []Command{
139 CommandSchema,
140 CommandGet,
141 CommandEnable,
142 CommandDisable,
143 CommandUpdate,
144 CommandRestart,
145 CommandTest,
146 CommandUserconfig,
147 },
148 })
149 }
150
151 func newTestFn(id, cmd, name string, payload []byte) Function {
152 args := []string{id, cmd}
153 if name != "" {
154 args = append(args, name)
155 }
156 return NewFunction(functions.Function{
157 UID: "test-uid",
158 Args: args,
159 Payload: payload,
160 })
161 }
162
163 func TestHandler_WaitForDecision_MatchingEnableClearsWait(t *testing.T) {
164 cb := &mockCallbacks{}
165 h := newTestHandler(cb)
166
167 cfg := testConfig{
168 uid: "uid-job1",
169 key: "job1",
170 sourceType: "stock",
171 source: "mod/job1",
172 }
173 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
174
175 h.WaitForDecision(cfg)
176 assert.True(t, h.WaitingForDecision())
177
178 h.SyncDecision(newTestFn("test:job1", "enable", "", nil))
179 assert.False(t, h.WaitingForDecision())
180 }
181
182 func TestHandler_WaitForDecision_MismatchedCommandKeepsWait(t *testing.T) {
183 cb := &mockCallbacks{}
184 h := newTestHandler(cb)
185
186 waitCfg := testConfig{
187 uid: "uid-job1",
188 key: "job1",
189 sourceType: "stock",
190 source: "mod/job1",
191 }
192 otherCfg := testConfig{
193 uid: "uid-job2",
194 key: "job2",
195 sourceType: "stock",
196 source: "mod/job2",
197 }
198 h.exposed.Add(&Entry[testConfig]{Cfg: waitCfg, Status: StatusAccepted})
199 h.exposed.Add(&Entry[testConfig]{Cfg: otherCfg, Status: StatusAccepted})
200
201 h.WaitForDecision(waitCfg)
202 assert.True(t, h.WaitingForDecision())
203
204 // Non enable/disable commands must not change wait state.
205 h.SyncDecision(newTestFn("test:job1", "schema", "", nil))
206 assert.True(t, h.WaitingForDecision())
207
208 // Enable/disable for a different key must not clear wait state.
209 h.SyncDecision(newTestFn("test:job2", "disable", "", nil))
210 assert.True(t, h.WaitingForDecision())
211
212 // Matching command clears wait state.
213 h.SyncDecision(newTestFn("test:job1", "disable", "", nil))
214 assert.False(t, h.WaitingForDecision())
215 }
216
217 func TestHandler_WaitForDecision_TimeoutClearsWait(t *testing.T) {
218 cb := &mockCallbacks{}
219 h := newTestHandlerWithWaitTimeout(cb, 5*time.Second)
220
221 cfg := testConfig{
222 uid: "uid-job1",
223 key: "job1",
224 sourceType: "stock",
225 source: "mod/job1",
226 }
227 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
228
229 base := time.Unix(1000, 0)
230 h.waitGate.setNow(func() time.Time { return base })
231
232 h.WaitForDecision(cfg)
233 assert.True(t, h.WaitingForDecision())
234
235 h.waitGate.setNow(func() time.Time { return base.Add(4 * time.Second) })
236 remaining, ok := h.WaitDecisionRemaining()
237 assert.True(t, ok)
238 assert.Equal(t, time.Second, remaining)
239
240 _, timedOut := h.ExpireWaitDecision()
241 assert.False(t, timedOut)
242 assert.True(t, h.WaitingForDecision())
243
244 h.waitGate.setNow(func() time.Time { return base.Add(5 * time.Second) })
245 event, timedOut := h.ExpireWaitDecision()
246 assert.True(t, timedOut)
247 assert.Equal(t, "mod/job1", event.Key)
248 assert.Equal(t, 5*time.Second, event.Threshold)
249 assert.Equal(t, 5*time.Second, event.Elapsed)
250 assert.False(t, h.WaitingForDecision())
251
252 _, ok = h.WaitDecisionRemaining()
253 assert.False(t, ok)
254 }
255
256 func TestHandler_WaitForDecision_TimeoutDisabledKeepsWait(t *testing.T) {
257 cb := &mockCallbacks{}
258 h := newTestHandlerWithWaitTimeout(cb, 0)
259
260 cfg := testConfig{
261 uid: "uid-job1",
262 key: "job1",
263 sourceType: "stock",
264 source: "mod/job1",
265 }
266 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
267
268 base := time.Unix(1000, 0)
269 h.waitGate.setNow(func() time.Time { return base })
270 h.WaitForDecision(cfg)
271
272 h.waitGate.setNow(func() time.Time { return base.Add(24 * time.Hour) })
273 _, timedOut := h.ExpireWaitDecision()
274 assert.False(t, timedOut)
275 assert.True(t, h.WaitingForDecision())
276 }
277
278 func TestHandler_NextWaitDecisionStep_Command(t *testing.T) {
279 cb := &mockCallbacks{}
280 h := newTestHandlerWithWaitTimeout(cb, 5*time.Second)
281
282 cfg := testConfig{
283 uid: "uid-job1",
284 key: "job1",
285 sourceType: "stock",
286 source: "mod/job1",
287 }
288 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
289 h.WaitForDecision(cfg)
290
291 ch := make(chan Function, 1)
292 fn := newTestFn("test:job1", "enable", "", nil)
293 ch <- fn
294
295 step, ok := h.NextWaitDecisionStep(context.Background(), ch)
296 require.True(t, ok)
297 require.True(t, step.HasCommand)
298 assert.Equal(t, fn.UID(), step.Command.UID())
299 assert.False(t, step.TimedOut)
300 }
301
302 func TestHandler_NextWaitDecisionStep_Timeout(t *testing.T) {
303 cb := &mockCallbacks{}
304 h := newTestHandlerWithWaitTimeout(cb, 20*time.Millisecond)
305
306 cfg := testConfig{
307 uid: "uid-job1",
308 key: "job1",
309 sourceType: "stock",
310 source: "mod/job1",
311 }
312 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
313 h.WaitForDecision(cfg)
314
315 ch := make(chan Function)
316 step, ok := h.NextWaitDecisionStep(context.Background(), ch)
317 require.True(t, ok)
318 require.True(t, step.TimedOut)
319 assert.Equal(t, "mod/job1", step.Timeout.Key)
320 assert.False(t, h.WaitingForDecision())
321 }
322
323 func TestHandler_NextWaitDecisionStep_ContextCancel(t *testing.T) {
324 cb := &mockCallbacks{}
325 h := newTestHandlerWithWaitTimeout(cb, 5*time.Second)
326
327 cfg := testConfig{
328 uid: "uid-job1",
329 key: "job1",
330 sourceType: "stock",
331 source: "mod/job1",
332 }
333 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
334 h.WaitForDecision(cfg)
335
336 ctx, cancel := context.WithCancel(context.Background())
337 cancel()
338
339 ch := make(chan Function)
340 _, ok := h.NextWaitDecisionStep(ctx, ch)
341 assert.False(t, ok)
342 assert.True(t, h.WaitingForDecision())
343 }
344
345 func TestHandler_AddDiscoveredConfig_TracksSeenAndExposed(t *testing.T) {
346 cb := &mockCallbacks{}
347 h := newTestHandler(cb)
348
349 cfg := testConfig{
350 uid: "uid-job1",
351 key: "job1",
352 sourceType: "stock",
353 source: "file=/tmp/job1.conf",
354 }
355
356 h.RememberDiscoveredConfig(cfg)
357 _, ok := h.seen.Lookup(cfg)
358 require.True(t, ok, "config should be remembered in seen cache")
359
360 entry := h.AddDiscoveredConfig(cfg, StatusAccepted)
361 require.NotNil(t, entry)
362 assert.Equal(t, StatusAccepted, entry.Status)
363 assert.Equal(t, cfg.UID(), entry.Cfg.UID())
364
365 exposed, ok := h.exposed.LookupByKey(cfg.ExposedKey())
366 require.True(t, ok, "config should be exposed")
367 assert.Equal(t, cfg.UID(), exposed.Cfg.UID())
368 assert.Equal(t, StatusAccepted, exposed.Status)
369 }
370
371 func TestHandler_RemoveDiscoveredConfig_MismatchedExposedUID(t *testing.T) {
372 cb := &mockCallbacks{}
373 h := newTestHandler(cb)
374
375 cfg := testConfig{
376 uid: "uid-stock",
377 key: "job1",
378 sourceType: "stock",
379 source: "file=/tmp/job1.conf",
380 }
381 other := testConfig{
382 uid: "uid-dyncfg",
383 key: "job1",
384 sourceType: "dyncfg",
385 source: "dyncfg=user",
386 }
387
388 h.seen.Add(cfg)
389 h.exposed.Add(&Entry[testConfig]{Cfg: other, Status: StatusRunning})
390
391 entry, ok := h.RemoveDiscoveredConfig(cfg)
392 require.False(t, ok, "mismatched exposed uid should not return an exposed entry")
393 require.Nil(t, entry)
394
395 _, stillSeen := h.seen.Lookup(cfg)
396 assert.False(t, stillSeen, "seen config should be removed")
397 exposed, stillExposed := h.exposed.LookupByKey(cfg.ExposedKey())
398 require.True(t, stillExposed, "exposed entry with different uid should be preserved")
399 assert.Equal(t, other.UID(), exposed.Cfg.UID())
400 }
401
402 // --- ExtractKey Failure Tests ---
403
404 func TestCmdAdd_ExtractKeyFailure(t *testing.T) {
405 cb := &mockCallbacks{}
406 h := newTestHandler(cb)
407
408 // ID without ":" causes default ExtractKey to return false.
409 fn := newTestFn("badid", "add", "job1", []byte(`{}`))
410 h.CmdAdd(fn)
411
412 assert.Equal(t, 0, h.exposed.Count())
413 }
414
415 func TestCmdEnable_ExtractKeyFailure(t *testing.T) {
416 cb := &mockCallbacks{}
417 h := newTestHandler(cb)
418
419 fn := newTestFn("badid", "enable", "", nil)
420 h.CmdEnable(fn)
421
422 assert.Len(t, cb.startCalls, 0)
423 }
424
425 func TestCmdDisable_ExtractKeyFailure(t *testing.T) {
426 cb := &mockCallbacks{}
427 h := newTestHandler(cb)
428
429 fn := newTestFn("badid", "disable", "", nil)
430 h.CmdDisable(fn)
431
432 assert.Len(t, cb.stopCalls, 0)
433 }
434
435 func TestCmdRemove_ExtractKeyFailure(t *testing.T) {
436 cb := &mockCallbacks{}
437 h := newTestHandler(cb)
438
439 fn := newTestFn("badid", "remove", "", nil)
440 h.CmdRemove(fn)
441
442 assert.Len(t, cb.stopCalls, 0)
443 }
444
445 func TestCmdUpdate_ExtractKeyFailure(t *testing.T) {
446 cb := &mockCallbacks{}
447 h := newTestHandler(cb)
448
449 fn := newTestFn("badid", "update", "", []byte(`{}`))
450 h.CmdUpdate(fn)
451
452 assert.Len(t, cb.updateCalls, 0)
453 }
454
455 func TestCmdRestart_ExtractKeyFailure(t *testing.T) {
456 cb := &mockCallbacks{}
457 h := newTestHandler(cb)
458
459 fn := newTestFn("badid", "restart", "", nil)
460 h.CmdRestart(fn)
461
462 assert.Len(t, cb.stopCalls, 0)
463 assert.Len(t, cb.startCalls, 0)
464 }
465
466 // --- CmdAdd Tests ---
467
468 func TestCmdAdd_Success(t *testing.T) {
469 cb := &mockCallbacks{}
470 h := newTestHandler(cb)
471
472 fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
473 h.CmdAdd(fn)
474
475 // Config should be in both caches.
476 _, ok := h.seen.LookupByUID("dyncfg:job1")
477 assert.True(t, ok, "config should be in seen cache")
478
479 entry, ok := h.exposed.LookupByKey("job1")
480 require.True(t, ok, "config should be in exposed cache")
481 assert.Equal(t, StatusAccepted, entry.Status)
482 }
483
484 func TestCmdAdd_InvalidArgs(t *testing.T) {
485 cb := &mockCallbacks{}
486 h := newTestHandler(cb)
487
488 // Only 2 args (need 3).
489 fn := newTestFn("test:job1", "add", "", nil)
490 fn.fn.Args = fn.fn.Args[:2]
491 h.CmdAdd(fn)
492
493 assert.Equal(t, 0, h.exposed.Count())
494 }
495
496 func TestCmdAdd_NoPayload(t *testing.T) {
497 cb := &mockCallbacks{}
498 h := newTestHandler(cb)
499
500 fn := newTestFn("test:job1", "add", "job1", nil)
501 h.CmdAdd(fn)
502
503 assert.Equal(t, 0, h.exposed.Count())
504 }
505
506 func TestCmdAdd_InvalidJobName(t *testing.T) {
507 cb := &mockCallbacks{}
508 h := newTestHandler(cb)
509
510 cb.extractKeyFn = func(fn Function) (string, string, bool) {
511 return "bad.name", "bad.name", true
512 }
513
514 fn := newTestFn("test:bad.name", "add", "bad.name", []byte(`{}`))
515 h.CmdAdd(fn)
516
517 assert.Equal(t, 0, h.exposed.Count())
518 }
519
520 func TestCmdAdd_ParseError(t *testing.T) {
521 cb := &mockCallbacks{}
522 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
523 return testConfig{}, errors.New("bad config")
524 }
525 h := newTestHandler(cb)
526
527 fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
528 h.CmdAdd(fn)
529
530 assert.Equal(t, 0, h.exposed.Count())
531 }
532
533 func TestCmdAdd_ReplacesExisting(t *testing.T) {
534 cb := &mockCallbacks{}
535 h := newTestHandler(cb)
536
537 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
538 h.seen.Add(oldCfg)
539 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
540
541 fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
542 h.CmdAdd(fn)
543
544 // Old should be stopped, new should be in cache.
545 require.Len(t, cb.stopCalls, 1)
546 assert.Equal(t, "job1", cb.stopCalls[0].ExposedKey())
547
548 entry, ok := h.exposed.LookupByKey("job1")
549 require.True(t, ok)
550 assert.Equal(t, StatusAccepted, entry.Status)
551 }
552
553 func TestCmdAdd_ReplacesExisting_KeepsNonDyncfgInSeen(t *testing.T) {
554 cb := &mockCallbacks{}
555 h := newTestHandler(cb)
556
557 // Existing is a stock config — should NOT be removed from seen.
558 oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
559 h.seen.Add(oldCfg)
560 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
561
562 fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
563 h.CmdAdd(fn)
564
565 // Stock config stays in seen (for re-promotion).
566 _, ok := h.seen.LookupByUID("stock:job1")
567 assert.True(t, ok, "stock config should remain in seen cache")
568 }
569
570 // --- CmdEnable Tests ---
571
572 func TestCmdEnable_Success(t *testing.T) {
573 cb := &mockCallbacks{}
574 h := newTestHandler(cb)
575
576 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
577 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
578
579 fn := newTestFn("test:job1", "enable", "", nil)
580 h.CmdEnable(fn)
581
582 entry, _ := h.exposed.LookupByKey("job1")
583 assert.Equal(t, StatusRunning, entry.Status)
584 assert.Len(t, cb.startCalls, 1)
585 assert.Len(t, cb.statusCalls, 1)
586 }
587
588 func TestCmdEnable_AlreadyRunning(t *testing.T) {
589 cb := &mockCallbacks{}
590 h := newTestHandler(cb)
591
592 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
593 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
594
595 fn := newTestFn("test:job1", "enable", "", nil)
596 h.CmdEnable(fn)
597
598 // No Start called, no OnStatusChange.
599 assert.Len(t, cb.startCalls, 0)
600 assert.Len(t, cb.statusCalls, 0)
601 }
602
603 func TestCmdEnable_NotFound(t *testing.T) {
604 cb := &mockCallbacks{}
605 h := newTestHandler(cb)
606
607 fn := newTestFn("test:job1", "enable", "", nil)
608 h.CmdEnable(fn)
609
610 assert.Len(t, cb.startCalls, 0)
611 }
612
613 func TestCmdEnable_StartFails_RegularError(t *testing.T) {
614 cb := &mockCallbacks{}
615 cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
616 h := newTestHandler(cb)
617
618 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
619 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
620
621 fn := newTestFn("test:job1", "enable", "", nil)
622 h.CmdEnable(fn)
623
624 // Stock config should be removed on regular (non-coded) error.
625 _, ok := h.exposed.LookupByKey("job1")
626 assert.False(t, ok, "stock config should be removed from exposed on enable failure")
627 }
628
629 func TestCmdEnable_StartFails_CodedError(t *testing.T) {
630 cb := &mockCallbacks{}
631 cb.startFn = func(_ testConfig) error {
632 return &codedErr{err: errors.New("validation failed"), code: 400}
633 }
634 h := newTestHandler(cb)
635
636 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
637 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
638
639 fn := newTestFn("test:job1", "enable", "", nil)
640 h.CmdEnable(fn)
641
642 // Stock config should NOT be removed on coded error.
643 entry, ok := h.exposed.LookupByKey("job1")
644 require.True(t, ok, "stock config should stay on coded error")
645 assert.Equal(t, StatusFailed, entry.Status)
646 }
647
648 func TestCmdEnable_FromDisabled(t *testing.T) {
649 cb := &mockCallbacks{}
650 h := newTestHandler(cb)
651
652 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
653 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
654
655 fn := newTestFn("test:job1", "enable", "", nil)
656 h.CmdEnable(fn)
657
658 entry, _ := h.exposed.LookupByKey("job1")
659 assert.Equal(t, StatusRunning, entry.Status)
660 require.Len(t, cb.statusCalls, 1)
661 assert.Equal(t, StatusDisabled, cb.statusCalls[0].oldStatus)
662 }
663
664 // --- CmdDisable Tests ---
665
666 func TestCmdDisable_FromRunning(t *testing.T) {
667 cb := &mockCallbacks{}
668 h := newTestHandler(cb)
669
670 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
671 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
672
673 fn := newTestFn("test:job1", "disable", "", nil)
674 h.CmdDisable(fn)
675
676 entry, _ := h.exposed.LookupByKey("job1")
677 assert.Equal(t, StatusDisabled, entry.Status)
678 assert.Len(t, cb.stopCalls, 1)
679 require.Len(t, cb.statusCalls, 1)
680 assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
681 }
682
683 func TestCmdDisable_AlreadyDisabled(t *testing.T) {
684 cb := &mockCallbacks{}
685 h := newTestHandler(cb)
686
687 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
688 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
689
690 fn := newTestFn("test:job1", "disable", "", nil)
691 h.CmdDisable(fn)
692
693 assert.Len(t, cb.stopCalls, 0)
694 assert.Len(t, cb.statusCalls, 0)
695 }
696
697 func TestCmdDisable_FromFailed(t *testing.T) {
698 cb := &mockCallbacks{}
699 h := newTestHandler(cb)
700
701 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
702 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
703
704 fn := newTestFn("test:job1", "disable", "", nil)
705 h.CmdDisable(fn)
706
707 // Stop called unconditionally (may have retry tasks to cancel).
708 assert.Len(t, cb.stopCalls, 1)
709 entry, _ := h.exposed.LookupByKey("job1")
710 assert.Equal(t, StatusDisabled, entry.Status)
711 }
712
713 func TestCmdDisable_NotFound(t *testing.T) {
714 cb := &mockCallbacks{}
715 h := newTestHandler(cb)
716
717 fn := newTestFn("test:job1", "disable", "", nil)
718 h.CmdDisable(fn)
719
720 assert.Len(t, cb.stopCalls, 0)
721 }
722
723 // --- CmdRemove Tests ---
724
725 func TestCmdRemove_DyncfgConfig(t *testing.T) {
726 cb := &mockCallbacks{}
727 h := newTestHandler(cb)
728
729 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
730 h.seen.Add(cfg)
731 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
732
733 fn := newTestFn("test:job1", "remove", "", nil)
734 h.CmdRemove(fn)
735
736 _, ok := h.seen.LookupByUID("dyncfg:job1")
737 assert.False(t, ok, "should be removed from seen")
738
739 _, ok = h.exposed.LookupByKey("job1")
740 assert.False(t, ok, "should be removed from exposed")
741
742 assert.Len(t, cb.stopCalls, 1)
743 }
744
745 func TestCmdRemove_NonDyncfg_Rejected(t *testing.T) {
746 cb := &mockCallbacks{}
747 h := newTestHandler(cb)
748
749 cfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
750 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
751
752 fn := newTestFn("test:job1", "remove", "", nil)
753 h.CmdRemove(fn)
754
755 // Should still be in cache — removal rejected.
756 _, ok := h.exposed.LookupByKey("job1")
757 assert.True(t, ok, "non-dyncfg config should not be removed")
758 assert.Len(t, cb.stopCalls, 0)
759 }
760
761 func TestCmdRemove_NotFound(t *testing.T) {
762 cb := &mockCallbacks{}
763 h := newTestHandler(cb)
764
765 fn := newTestFn("test:job1", "remove", "", nil)
766 h.CmdRemove(fn)
767
768 assert.Len(t, cb.stopCalls, 0)
769 }
770
771 // --- CmdUpdate Tests ---
772
773 func TestCmdUpdate_NonConversion_Success(t *testing.T) {
774 cb := &mockCallbacks{}
775 h := newTestHandler(cb)
776
777 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
778 h.seen.Add(oldCfg)
779 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
780
781 // ParseAndValidate returns config with different hash.
782 cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
783 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
784 }
785
786 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
787 h.CmdUpdate(fn)
788
789 // Should call Update (not Stop+Start).
790 assert.Len(t, cb.updateCalls, 1)
791 assert.Len(t, cb.stopCalls, 0)
792 assert.Len(t, cb.startCalls, 0)
793
794 entry, ok := h.exposed.LookupByKey("job1")
795 require.True(t, ok)
796 assert.Equal(t, StatusRunning, entry.Status)
797 assert.Equal(t, uint64(200), entry.Cfg.Hash())
798 }
799
800 func TestCmdUpdate_NonConversion_NoOp(t *testing.T) {
801 cb := &mockCallbacks{}
802 h := newTestHandler(cb)
803
804 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
805 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
806
807 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
808 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}, nil
809 }
810
811 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
812 h.CmdUpdate(fn)
813
814 // No-op: same hash, running, not conversion.
815 assert.Len(t, cb.updateCalls, 0)
816 assert.Len(t, cb.stopCalls, 0)
817 assert.Len(t, cb.startCalls, 0)
818 }
819
820 func TestCmdUpdate_Conversion_Success(t *testing.T) {
821 cb := &mockCallbacks{}
822 h := newTestHandler(cb)
823
824 oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
825 h.seen.Add(oldCfg)
826 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
827
828 cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
829 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
830 }
831
832 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
833 h.CmdUpdate(fn)
834
835 // Conversion uses Stop + Start, not Update.
836 assert.Len(t, cb.stopCalls, 1)
837 assert.Len(t, cb.startCalls, 1)
838 assert.Len(t, cb.updateCalls, 0)
839
840 entry, _ := h.exposed.LookupByKey("job1")
841 assert.Equal(t, StatusRunning, entry.Status)
842 assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
843
844 // Old stock config should still be in seen (for re-promotion).
845 _, ok := h.seen.LookupByUID("stock:job1")
846 assert.True(t, ok, "stock config should stay in seen for conversion")
847 }
848
849 func TestCmdUpdate_Disabled_PreservesStatus(t *testing.T) {
850 cb := &mockCallbacks{}
851 h := newTestHandler(cb)
852
853 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
854 h.seen.Add(oldCfg)
855 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
856
857 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
858 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
859 }
860
861 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
862 h.CmdUpdate(fn)
863
864 // Should NOT start, should preserve Disabled.
865 assert.Len(t, cb.startCalls, 0)
866 assert.Len(t, cb.updateCalls, 0)
867
868 entry, _ := h.exposed.LookupByKey("job1")
869 assert.Equal(t, StatusDisabled, entry.Status)
870 }
871
872 func TestCmdUpdate_Accepted_Rejected(t *testing.T) {
873 cb := &mockCallbacks{}
874 h := newTestHandler(cb)
875
876 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
877 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusAccepted})
878
879 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
880 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
881 }
882
883 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
884 h.CmdUpdate(fn)
885
886 // Accepted configs can't be updated.
887 assert.Len(t, cb.updateCalls, 0)
888 assert.Len(t, cb.startCalls, 0)
889
890 entry, _ := h.exposed.LookupByKey("job1")
891 assert.Equal(t, StatusAccepted, entry.Status)
892 }
893
894 func TestCmdUpdate_NotFound(t *testing.T) {
895 cb := &mockCallbacks{}
896 h := newTestHandler(cb)
897
898 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
899 h.CmdUpdate(fn)
900
901 assert.Len(t, cb.updateCalls, 0)
902 }
903
904 func TestCmdUpdate_ParseError(t *testing.T) {
905 cb := &mockCallbacks{}
906 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
907 return testConfig{}, errors.New("bad config")
908 }
909 h := newTestHandler(cb)
910
911 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
912 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
913
914 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
915 h.CmdUpdate(fn)
916
917 // Parse error should not modify cache.
918 entry, _ := h.exposed.LookupByKey("job1")
919 assert.Equal(t, StatusRunning, entry.Status)
920 }
921
922 func TestCmdUpdate_NonConversion_StartFails(t *testing.T) {
923 cb := &mockCallbacks{}
924 cb.updateFn = func(_, _ testConfig) error { return errors.New("update failed") }
925 h := newTestHandler(cb)
926
927 oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
928 h.seen.Add(oldCfg)
929 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
930
931 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
932 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
933 }
934
935 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
936 h.CmdUpdate(fn)
937
938 entry, _ := h.exposed.LookupByKey("job1")
939 assert.Equal(t, StatusFailed, entry.Status)
940 }
941
942 func TestCmdUpdate_NonConversion_StartFails_NonDisruptiveRollback(t *testing.T) {
943 cb := &mockCallbacks{}
944 cb.updateFn = func(_, _ testConfig) error {
945 return MarkNonDisruptiveUpdate(errors.New("update preflight failed"))
946 }
947 h := newTestHandler(cb)
948
949 oldCfg := testConfig{uid: "dyncfg:job1:v1", key: "job1", sourceType: "dyncfg", hash: 100}
950 newCfg := testConfig{uid: "dyncfg:job1:v2", key: "job1", sourceType: "dyncfg", hash: 200}
951 h.seen.Add(oldCfg)
952 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
953
954 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
955 return newCfg, nil
956 }
957
958 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
959 h.CmdUpdate(fn)
960
961 entry, _ := h.exposed.LookupByKey("job1")
962 assert.Equal(t, StatusRunning, entry.Status)
963 assert.Equal(t, oldCfg.UID(), entry.Cfg.UID())
964
965 _, ok := h.seen.LookupByUID(oldCfg.UID())
966 assert.True(t, ok, "old config should be restored in seen cache")
967
968 _, ok = h.seen.LookupByUID(newCfg.UID())
969 assert.False(t, ok, "new config should be removed from seen cache on rollback")
970 }
971
972 func TestCmdUpdate_Conversion_StartFails(t *testing.T) {
973 cb := &mockCallbacks{}
974 cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
975 h := newTestHandler(cb)
976
977 oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
978 h.seen.Add(oldCfg)
979 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
980
981 cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
982 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
983 }
984
985 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
986 h.CmdUpdate(fn)
987
988 // Conversion uses Stop + Start; Start fails → Failed status.
989 assert.Len(t, cb.stopCalls, 1)
990 assert.Len(t, cb.startCalls, 1)
991
992 entry, _ := h.exposed.LookupByKey("job1")
993 assert.Equal(t, StatusFailed, entry.Status)
994 assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
995 }
996
997 func TestCmdUpdate_NoPayload(t *testing.T) {
998 cb := &mockCallbacks{}
999 h := newTestHandler(cb)
1000
1001 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1002 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
1003
1004 // No payload (nil).
1005 fn := newTestFn("test:job1", "update", "job1", nil)
1006 h.CmdUpdate(fn)
1007
1008 // Should fail with missing payload, not modify cache.
1009 assert.Len(t, cb.updateCalls, 0)
1010 entry, _ := h.exposed.LookupByKey("job1")
1011 assert.Equal(t, StatusRunning, entry.Status)
1012 }
1013
1014 func TestCmdUpdate_Conversion_Disabled(t *testing.T) {
1015 cb := &mockCallbacks{}
1016 h := newTestHandler(cb)
1017
1018 oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
1019 h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
1020
1021 cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
1022 return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", source: "test"}, nil
1023 }
1024
1025 fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
1026 h.CmdUpdate(fn)
1027
1028 // Conversion with Disabled: Stop old, update caches, preserve Disabled.
1029 assert.Len(t, cb.stopCalls, 1)
1030 assert.Len(t, cb.startCalls, 0)
1031
1032 entry, _ := h.exposed.LookupByKey("job1")
1033 assert.Equal(t, StatusDisabled, entry.Status)
1034 }
1035
1036 // --- CmdRestart Tests ---
1037
1038 func TestCmdRestart_FromRunning(t *testing.T) {
1039 cb := &mockCallbacks{}
1040 h := newTestHandler(cb)
1041
1042 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1043 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
1044
1045 fn := newTestFn("test:job1", "restart", "", nil)
1046 h.CmdRestart(fn)
1047
1048 assert.Len(t, cb.stopCalls, 1)
1049 assert.Len(t, cb.startCalls, 1)
1050
1051 entry, _ := h.exposed.LookupByKey("job1")
1052 assert.Equal(t, StatusRunning, entry.Status)
1053 require.Len(t, cb.statusCalls, 1)
1054 assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
1055 }
1056
1057 func TestCmdRestart_FromFailed(t *testing.T) {
1058 cb := &mockCallbacks{}
1059 h := newTestHandler(cb)
1060
1061 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1062 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
1063
1064 fn := newTestFn("test:job1", "restart", "", nil)
1065 h.CmdRestart(fn)
1066
1067 assert.Len(t, cb.stopCalls, 1)
1068 assert.Len(t, cb.startCalls, 1)
1069
1070 entry, _ := h.exposed.LookupByKey("job1")
1071 assert.Equal(t, StatusRunning, entry.Status)
1072 require.Len(t, cb.statusCalls, 1)
1073 assert.Equal(t, StatusFailed, cb.statusCalls[0].oldStatus)
1074 }
1075
1076 func TestCmdRestart_Accepted_Rejected(t *testing.T) {
1077 cb := &mockCallbacks{}
1078 h := newTestHandler(cb)
1079
1080 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1081 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
1082
1083 fn := newTestFn("test:job1", "restart", "", nil)
1084 h.CmdRestart(fn)
1085
1086 assert.Len(t, cb.stopCalls, 0)
1087 assert.Len(t, cb.startCalls, 0)
1088
1089 entry, _ := h.exposed.LookupByKey("job1")
1090 assert.Equal(t, StatusAccepted, entry.Status)
1091 }
1092
1093 func TestCmdRestart_Disabled_Rejected(t *testing.T) {
1094 cb := &mockCallbacks{}
1095 h := newTestHandler(cb)
1096
1097 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1098 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
1099
1100 fn := newTestFn("test:job1", "restart", "", nil)
1101 h.CmdRestart(fn)
1102
1103 assert.Len(t, cb.stopCalls, 0)
1104 assert.Len(t, cb.startCalls, 0)
1105
1106 entry, _ := h.exposed.LookupByKey("job1")
1107 assert.Equal(t, StatusDisabled, entry.Status)
1108 }
1109
1110 func TestCmdRestart_NotFound(t *testing.T) {
1111 cb := &mockCallbacks{}
1112 h := newTestHandler(cb)
1113
1114 fn := newTestFn("test:job1", "restart", "", nil)
1115 h.CmdRestart(fn)
1116
1117 assert.Len(t, cb.stopCalls, 0)
1118 assert.Len(t, cb.startCalls, 0)
1119 }
1120
1121 func TestCmdRestart_StartFails(t *testing.T) {
1122 cb := &mockCallbacks{}
1123 cb.startFn = func(_ testConfig) error { return errors.New("restart failed") }
1124 h := newTestHandler(cb)
1125
1126 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1127 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
1128
1129 fn := newTestFn("test:job1", "restart", "", nil)
1130 h.CmdRestart(fn)
1131
1132 assert.Len(t, cb.stopCalls, 1)
1133 assert.Len(t, cb.startCalls, 1)
1134
1135 entry, _ := h.exposed.LookupByKey("job1")
1136 assert.Equal(t, StatusFailed, entry.Status)
1137 require.Len(t, cb.statusCalls, 1)
1138 assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
1139 }
1140
1141 func TestCmdRestart_StartFails_CodedError(t *testing.T) {
1142 cb := &mockCallbacks{}
1143 cb.startFn = func(_ testConfig) error {
1144 return &codedErr{err: errors.New("bad config"), code: 400}
1145 }
1146 h := newTestHandler(cb)
1147
1148 cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
1149 h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
1150
1151 fn := newTestFn("test:job1", "restart", "", nil)
1152 h.CmdRestart(fn)
1153
1154 entry, _ := h.exposed.LookupByKey("job1")
1155 assert.Equal(t, StatusFailed, entry.Status)
1156 }
1157
1158 // --- Notify Tests ---
1159
1160 func TestNotifyJobCreate_SupportedCommands(t *testing.T) {
1161 tests := []struct {
1162 name string
1163 commands []Command
1164 sourceType string
1165 wantRemove bool
1166 }{
1167 {"dyncfg with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "dyncfg", true},
1168 {"dyncfg no restart", []Command{CommandSchema, CommandGet}, "dyncfg", true},
1169 {"stock with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "stock", false},
1170 {"stock no restart", []Command{CommandSchema, CommandGet}, "stock", false},
1171 }
1172
1173 for _, tt := range tests {
1174 t.Run(tt.name, func(t *testing.T) {
1175 cb := &mockCallbacks{}
1176 h := newTestHandler(cb)
1177 h.jobCommands = tt.commands
1178
1179 cmds := h.jobSupportedCommands(tt.sourceType == "dyncfg")
1180
1181 // Base commands should always be present.
1182 for _, cmd := range tt.commands {
1183 assert.Contains(t, cmds, string(cmd))
1184 }
1185 if tt.wantRemove {
1186 assert.Contains(t, cmds, "remove")
1187 } else {
1188 assert.NotContains(t, cmds, "remove")
1189 }
1190 })
1191 }
1192 }
1193
1194 // --- Job-name rule tests ---
1195
1196 func TestJobNameRuleStrict(t *testing.T) {
1197 tests := map[string]struct {
1198 input string
1199 wantErr bool
1200 }{
1201 "valid": {input: "my_job"},
1202 "valid with numbers": {input: "job123"},
1203 "valid with dashes": {input: "my-job"},
1204 "space": {input: "my job", wantErr: true},
1205 "tab": {input: "my\tjob", wantErr: true},
1206 "dot": {input: "my.job", wantErr: true},
1207 "colon": {input: "my:job", wantErr: true},
1208 "empty": {input: ""},
1209 }
1210
1211 for name, tt := range tests {
1212 t.Run(name, func(t *testing.T) {
1213 err := JobNameRuleStrict(tt.input)
1214 if tt.wantErr {
1215 assert.Error(t, err, fmt.Sprintf("JobNameRuleStrict(%q) should fail", tt.input))
1216 } else {
1217 assert.NoError(t, err, fmt.Sprintf("JobNameRuleStrict(%q) should pass", tt.input))
1218 }
1219 })
1220 }
1221 }
1222
1223 func TestJobNameRuleAllowDots(t *testing.T) {
1224 tests := map[string]struct {
1225 input string
1226 wantErr bool
1227 }{
1228 "valid": {input: "my_job"},
1229 "valid with numbers": {input: "job123"},
1230 "valid with dashes": {input: "my-job"},
1231 "dotted name": {input: "my.job"},
1232 "fqdn": {input: "host.example.com"},
1233 "space": {input: "my job", wantErr: true},
1234 "tab": {input: "my\tjob", wantErr: true},
1235 "colon": {input: "my:job", wantErr: true},
1236 "empty": {input: ""},
1237 }
1238
1239 for name, tt := range tests {
1240 t.Run(name, func(t *testing.T) {
1241 err := JobNameRuleAllowDots(tt.input)
1242 if tt.wantErr {
1243 assert.Error(t, err, fmt.Sprintf("JobNameRuleAllowDots(%q) should fail", tt.input))
1244 } else {
1245 assert.NoError(t, err, fmt.Sprintf("JobNameRuleAllowDots(%q) should pass", tt.input))
1246 }
1247 })
1248 }
1249 }