1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package dyncfg
4
+
5
+import (
6
+ "bytes"
7
+ "errors"
8
+ "fmt"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/netdata/netdata/go/plugins/logger"
13
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/safewriter"
15
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/functions"
16
+
17
+ "github.com/stretchr/testify/assert"
18
+ "github.com/stretchr/testify/require"
19
+)
20
+
21
+// codedErr implements CodedError for testing.
22
+type codedErr struct {
23
+ err error
24
+ code int
25
+}
26
+
27
+func (e *codedErr) Error() string { return e.err.Error() }
28
+func (e *codedErr) Code() int { return e.code }
29
+
30
+// mockCallbacks records all callback invocations for verification.
31
+type mockCallbacks struct {
32
+ extractKeyFn func(fn Function) (string, string, bool)
33
+ parseAndValidateFn func(fn Function, name string) (testConfig, error)
34
+ startFn func(cfg testConfig) error
35
+ updateFn func(oldCfg, newCfg testConfig) error
36
+ stopFn func(cfg testConfig)
37
+ onStatusChangeFn func(entry *Entry[testConfig], oldStatus Status, fn Function)
38
+ configIDFn func(cfg testConfig) string
39
+
40
+ startCalls []testConfig
41
+ updateCalls []updateCall
42
+ stopCalls []testConfig
43
+ statusCalls []statusChangeCall
44
+}
45
+
46
+type updateCall struct {
47
+ oldCfg, newCfg testConfig
48
+}
49
+
50
+type statusChangeCall struct {
51
+ entry *Entry[testConfig]
52
+ oldStatus Status
53
+}
54
+
55
+func (m *mockCallbacks) ExtractKey(fn Function) (string, string, bool) {
56
+ if m.extractKeyFn != nil {
57
+ return m.extractKeyFn(fn)
58
+ }
59
+ // Default: extract key from ID like "prefix:name".
60
+ parts := strings.SplitN(fn.ID(), ":", 2)
61
+ if len(parts) != 2 || parts[1] == "" {
62
+ return "", "", false
63
+ }
64
+ return parts[1], parts[1], true
65
+}
66
+
67
+func (m *mockCallbacks) ParseAndValidate(fn Function, name string) (testConfig, error) {
68
+ if m.parseAndValidateFn != nil {
69
+ return m.parseAndValidateFn(fn, name)
70
+ }
71
+ return testConfig{uid: "dyncfg:" + name, key: name, sourceType: "dyncfg", source: "test"}, nil
72
+}
73
+
74
+func (m *mockCallbacks) Start(cfg testConfig) error {
75
+ m.startCalls = append(m.startCalls, cfg)
76
+ if m.startFn != nil {
77
+ return m.startFn(cfg)
78
+ }
79
+ return nil
80
+}
81
+
82
+func (m *mockCallbacks) Update(oldCfg, newCfg testConfig) error {
83
+ m.updateCalls = append(m.updateCalls, updateCall{oldCfg, newCfg})
84
+ if m.updateFn != nil {
85
+ return m.updateFn(oldCfg, newCfg)
86
+ }
87
+ return nil
88
+}
89
+
90
+func (m *mockCallbacks) Stop(cfg testConfig) {
91
+ m.stopCalls = append(m.stopCalls, cfg)
92
+ if m.stopFn != nil {
93
+ m.stopFn(cfg)
94
+ }
95
+}
96
+
97
+func (m *mockCallbacks) OnStatusChange(entry *Entry[testConfig], oldStatus Status, fn Function) {
98
+ m.statusCalls = append(m.statusCalls, statusChangeCall{entry: entry, oldStatus: oldStatus})
99
+ if m.onStatusChangeFn != nil {
100
+ m.onStatusChangeFn(entry, oldStatus, fn)
101
+ }
102
+}
103
+
104
+func (m *mockCallbacks) ConfigID(cfg testConfig) string {
105
+ if m.configIDFn != nil {
106
+ return m.configIDFn(cfg)
107
+ }
108
+ return "test:" + cfg.ExposedKey()
109
+}
110
+
111
+func newTestHandler(cb *mockCallbacks) *Handler[testConfig] {
112
+ var buf bytes.Buffer
113
+ api := NewResponder(netdataapi.New(safewriter.New(&buf)))
114
+ return NewHandler(HandlerOpts[testConfig]{
115
+ Logger: logger.New(),
116
+ API: api,
117
+ Seen: NewSeenCache[testConfig](),
118
+ Exposed: NewExposedCache[testConfig](),
119
+ Callbacks: cb,
120
+
121
+ Path: "/test/path",
122
+ EnableFailCode: 200,
123
+ RemoveStockOnEnableFail: true,
124
+ JobCommands: []Command{
125
+ CommandSchema,
126
+ CommandGet,
127
+ CommandEnable,
128
+ CommandDisable,
129
+ CommandUpdate,
130
+ CommandRestart,
131
+ CommandTest,
132
+ CommandUserconfig,
133
+ },
134
+ })
135
+}
136
+
137
+func newTestFn(id, cmd, name string, payload []byte) Function {
138
+ args := []string{id, cmd}
139
+ if name != "" {
140
+ args = append(args, name)
141
+ }
142
+ return NewFunction(functions.Function{
143
+ UID: "test-uid",
144
+ Args: args,
145
+ Payload: payload,
146
+ })
147
+}
148
+
149
+// --- ExtractKey Failure Tests ---
150
+
151
+func TestCmdAdd_ExtractKeyFailure(t *testing.T) {
152
+ cb := &mockCallbacks{}
153
+ h := newTestHandler(cb)
154
+
155
+ // ID without ":" causes default ExtractKey to return false.
156
+ fn := newTestFn("badid", "add", "job1", []byte(`{}`))
157
+ h.CmdAdd(fn)
158
+
159
+ assert.Equal(t, 0, h.exposed.Count())
160
+}
161
+
162
+func TestCmdEnable_ExtractKeyFailure(t *testing.T) {
163
+ cb := &mockCallbacks{}
164
+ h := newTestHandler(cb)
165
+
166
+ fn := newTestFn("badid", "enable", "", nil)
167
+ h.CmdEnable(fn)
168
+
169
+ assert.Len(t, cb.startCalls, 0)
170
+}
171
+
172
+func TestCmdDisable_ExtractKeyFailure(t *testing.T) {
173
+ cb := &mockCallbacks{}
174
+ h := newTestHandler(cb)
175
+
176
+ fn := newTestFn("badid", "disable", "", nil)
177
+ h.CmdDisable(fn)
178
+
179
+ assert.Len(t, cb.stopCalls, 0)
180
+}
181
+
182
+func TestCmdRemove_ExtractKeyFailure(t *testing.T) {
183
+ cb := &mockCallbacks{}
184
+ h := newTestHandler(cb)
185
+
186
+ fn := newTestFn("badid", "remove", "", nil)
187
+ h.CmdRemove(fn)
188
+
189
+ assert.Len(t, cb.stopCalls, 0)
190
+}
191
+
192
+func TestCmdUpdate_ExtractKeyFailure(t *testing.T) {
193
+ cb := &mockCallbacks{}
194
+ h := newTestHandler(cb)
195
+
196
+ fn := newTestFn("badid", "update", "", []byte(`{}`))
197
+ h.CmdUpdate(fn)
198
+
199
+ assert.Len(t, cb.updateCalls, 0)
200
+}
201
+
202
+func TestCmdRestart_ExtractKeyFailure(t *testing.T) {
203
+ cb := &mockCallbacks{}
204
+ h := newTestHandler(cb)
205
+
206
+ fn := newTestFn("badid", "restart", "", nil)
207
+ h.CmdRestart(fn)
208
+
209
+ assert.Len(t, cb.stopCalls, 0)
210
+ assert.Len(t, cb.startCalls, 0)
211
+}
212
+
213
+// --- CmdAdd Tests ---
214
+
215
+func TestCmdAdd_Success(t *testing.T) {
216
+ cb := &mockCallbacks{}
217
+ h := newTestHandler(cb)
218
+
219
+ fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
220
+ h.CmdAdd(fn)
221
+
222
+ // Config should be in both caches.
223
+ _, ok := h.seen.LookupByUID("dyncfg:job1")
224
+ assert.True(t, ok, "config should be in seen cache")
225
+
226
+ entry, ok := h.exposed.LookupByKey("job1")
227
+ require.True(t, ok, "config should be in exposed cache")
228
+ assert.Equal(t, StatusAccepted, entry.Status)
229
+}
230
+
231
+func TestCmdAdd_InvalidArgs(t *testing.T) {
232
+ cb := &mockCallbacks{}
233
+ h := newTestHandler(cb)
234
+
235
+ // Only 2 args (need 3).
236
+ fn := newTestFn("test:job1", "add", "", nil)
237
+ fn.fn.Args = fn.fn.Args[:2]
238
+ h.CmdAdd(fn)
239
+
240
+ assert.Equal(t, 0, h.exposed.Count())
241
+}
242
+
243
+func TestCmdAdd_NoPayload(t *testing.T) {
244
+ cb := &mockCallbacks{}
245
+ h := newTestHandler(cb)
246
+
247
+ fn := newTestFn("test:job1", "add", "job1", nil)
248
+ h.CmdAdd(fn)
249
+
250
+ assert.Equal(t, 0, h.exposed.Count())
251
+}
252
+
253
+func TestCmdAdd_InvalidJobName(t *testing.T) {
254
+ cb := &mockCallbacks{}
255
+ h := newTestHandler(cb)
256
+
257
+ cb.extractKeyFn = func(fn Function) (string, string, bool) {
258
+ return "bad.name", "bad.name", true
259
+ }
260
+
261
+ fn := newTestFn("test:bad.name", "add", "bad.name", []byte(`{}`))
262
+ h.CmdAdd(fn)
263
+
264
+ assert.Equal(t, 0, h.exposed.Count())
265
+}
266
+
267
+func TestCmdAdd_ParseError(t *testing.T) {
268
+ cb := &mockCallbacks{}
269
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
270
+ return testConfig{}, errors.New("bad config")
271
+ }
272
+ h := newTestHandler(cb)
273
+
274
+ fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
275
+ h.CmdAdd(fn)
276
+
277
+ assert.Equal(t, 0, h.exposed.Count())
278
+}
279
+
280
+func TestCmdAdd_ReplacesExisting(t *testing.T) {
281
+ cb := &mockCallbacks{}
282
+ h := newTestHandler(cb)
283
+
284
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
285
+ h.seen.Add(oldCfg)
286
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
287
+
288
+ fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
289
+ h.CmdAdd(fn)
290
+
291
+ // Old should be stopped, new should be in cache.
292
+ require.Len(t, cb.stopCalls, 1)
293
+ assert.Equal(t, "job1", cb.stopCalls[0].ExposedKey())
294
+
295
+ entry, ok := h.exposed.LookupByKey("job1")
296
+ require.True(t, ok)
297
+ assert.Equal(t, StatusAccepted, entry.Status)
298
+}
299
+
300
+func TestCmdAdd_ReplacesExisting_KeepsNonDyncfgInSeen(t *testing.T) {
301
+ cb := &mockCallbacks{}
302
+ h := newTestHandler(cb)
303
+
304
+ // Existing is a stock config — should NOT be removed from seen.
305
+ oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
306
+ h.seen.Add(oldCfg)
307
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
308
+
309
+ fn := newTestFn("test:job1", "add", "job1", []byte(`{}`))
310
+ h.CmdAdd(fn)
311
+
312
+ // Stock config stays in seen (for re-promotion).
313
+ _, ok := h.seen.LookupByUID("stock:job1")
314
+ assert.True(t, ok, "stock config should remain in seen cache")
315
+}
316
+
317
+// --- CmdEnable Tests ---
318
+
319
+func TestCmdEnable_Success(t *testing.T) {
320
+ cb := &mockCallbacks{}
321
+ h := newTestHandler(cb)
322
+
323
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
324
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
325
+
326
+ fn := newTestFn("test:job1", "enable", "", nil)
327
+ h.CmdEnable(fn)
328
+
329
+ entry, _ := h.exposed.LookupByKey("job1")
330
+ assert.Equal(t, StatusRunning, entry.Status)
331
+ assert.Len(t, cb.startCalls, 1)
332
+ assert.Len(t, cb.statusCalls, 1)
333
+}
334
+
335
+func TestCmdEnable_AlreadyRunning(t *testing.T) {
336
+ cb := &mockCallbacks{}
337
+ h := newTestHandler(cb)
338
+
339
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
340
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
341
+
342
+ fn := newTestFn("test:job1", "enable", "", nil)
343
+ h.CmdEnable(fn)
344
+
345
+ // No Start called, no OnStatusChange.
346
+ assert.Len(t, cb.startCalls, 0)
347
+ assert.Len(t, cb.statusCalls, 0)
348
+}
349
+
350
+func TestCmdEnable_NotFound(t *testing.T) {
351
+ cb := &mockCallbacks{}
352
+ h := newTestHandler(cb)
353
+
354
+ fn := newTestFn("test:job1", "enable", "", nil)
355
+ h.CmdEnable(fn)
356
+
357
+ assert.Len(t, cb.startCalls, 0)
358
+}
359
+
360
+func TestCmdEnable_StartFails_RegularError(t *testing.T) {
361
+ cb := &mockCallbacks{}
362
+ cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
363
+ h := newTestHandler(cb)
364
+
365
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
366
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
367
+
368
+ fn := newTestFn("test:job1", "enable", "", nil)
369
+ h.CmdEnable(fn)
370
+
371
+ // Stock config should be removed on regular (non-coded) error.
372
+ _, ok := h.exposed.LookupByKey("job1")
373
+ assert.False(t, ok, "stock config should be removed from exposed on enable failure")
374
+}
375
+
376
+func TestCmdEnable_StartFails_CodedError(t *testing.T) {
377
+ cb := &mockCallbacks{}
378
+ cb.startFn = func(_ testConfig) error {
379
+ return &codedErr{err: errors.New("validation failed"), code: 400}
380
+ }
381
+ h := newTestHandler(cb)
382
+
383
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "stock"}
384
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
385
+
386
+ fn := newTestFn("test:job1", "enable", "", nil)
387
+ h.CmdEnable(fn)
388
+
389
+ // Stock config should NOT be removed on coded error.
390
+ entry, ok := h.exposed.LookupByKey("job1")
391
+ require.True(t, ok, "stock config should stay on coded error")
392
+ assert.Equal(t, StatusFailed, entry.Status)
393
+}
394
+
395
+func TestCmdEnable_FromDisabled(t *testing.T) {
396
+ cb := &mockCallbacks{}
397
+ h := newTestHandler(cb)
398
+
399
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
400
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
401
+
402
+ fn := newTestFn("test:job1", "enable", "", nil)
403
+ h.CmdEnable(fn)
404
+
405
+ entry, _ := h.exposed.LookupByKey("job1")
406
+ assert.Equal(t, StatusRunning, entry.Status)
407
+ require.Len(t, cb.statusCalls, 1)
408
+ assert.Equal(t, StatusDisabled, cb.statusCalls[0].oldStatus)
409
+}
410
+
411
+// --- CmdDisable Tests ---
412
+
413
+func TestCmdDisable_FromRunning(t *testing.T) {
414
+ cb := &mockCallbacks{}
415
+ h := newTestHandler(cb)
416
+
417
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
418
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
419
+
420
+ fn := newTestFn("test:job1", "disable", "", nil)
421
+ h.CmdDisable(fn)
422
+
423
+ entry, _ := h.exposed.LookupByKey("job1")
424
+ assert.Equal(t, StatusDisabled, entry.Status)
425
+ assert.Len(t, cb.stopCalls, 1)
426
+ require.Len(t, cb.statusCalls, 1)
427
+ assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
428
+}
429
+
430
+func TestCmdDisable_AlreadyDisabled(t *testing.T) {
431
+ cb := &mockCallbacks{}
432
+ h := newTestHandler(cb)
433
+
434
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
435
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
436
+
437
+ fn := newTestFn("test:job1", "disable", "", nil)
438
+ h.CmdDisable(fn)
439
+
440
+ assert.Len(t, cb.stopCalls, 0)
441
+ assert.Len(t, cb.statusCalls, 0)
442
+}
443
+
444
+func TestCmdDisable_FromFailed(t *testing.T) {
445
+ cb := &mockCallbacks{}
446
+ h := newTestHandler(cb)
447
+
448
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
449
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
450
+
451
+ fn := newTestFn("test:job1", "disable", "", nil)
452
+ h.CmdDisable(fn)
453
+
454
+ // Stop called unconditionally (may have retry tasks to cancel).
455
+ assert.Len(t, cb.stopCalls, 1)
456
+ entry, _ := h.exposed.LookupByKey("job1")
457
+ assert.Equal(t, StatusDisabled, entry.Status)
458
+}
459
+
460
+func TestCmdDisable_NotFound(t *testing.T) {
461
+ cb := &mockCallbacks{}
462
+ h := newTestHandler(cb)
463
+
464
+ fn := newTestFn("test:job1", "disable", "", nil)
465
+ h.CmdDisable(fn)
466
+
467
+ assert.Len(t, cb.stopCalls, 0)
468
+}
469
+
470
+// --- CmdRemove Tests ---
471
+
472
+func TestCmdRemove_DyncfgConfig(t *testing.T) {
473
+ cb := &mockCallbacks{}
474
+ h := newTestHandler(cb)
475
+
476
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
477
+ h.seen.Add(cfg)
478
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
479
+
480
+ fn := newTestFn("test:job1", "remove", "", nil)
481
+ h.CmdRemove(fn)
482
+
483
+ _, ok := h.seen.LookupByUID("dyncfg:job1")
484
+ assert.False(t, ok, "should be removed from seen")
485
+
486
+ _, ok = h.exposed.LookupByKey("job1")
487
+ assert.False(t, ok, "should be removed from exposed")
488
+
489
+ assert.Len(t, cb.stopCalls, 1)
490
+}
491
+
492
+func TestCmdRemove_NonDyncfg_Rejected(t *testing.T) {
493
+ cb := &mockCallbacks{}
494
+ h := newTestHandler(cb)
495
+
496
+ cfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
497
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
498
+
499
+ fn := newTestFn("test:job1", "remove", "", nil)
500
+ h.CmdRemove(fn)
501
+
502
+ // Should still be in cache — removal rejected.
503
+ _, ok := h.exposed.LookupByKey("job1")
504
+ assert.True(t, ok, "non-dyncfg config should not be removed")
505
+ assert.Len(t, cb.stopCalls, 0)
506
+}
507
+
508
+func TestCmdRemove_NotFound(t *testing.T) {
509
+ cb := &mockCallbacks{}
510
+ h := newTestHandler(cb)
511
+
512
+ fn := newTestFn("test:job1", "remove", "", nil)
513
+ h.CmdRemove(fn)
514
+
515
+ assert.Len(t, cb.stopCalls, 0)
516
+}
517
+
518
+// --- CmdUpdate Tests ---
519
+
520
+func TestCmdUpdate_NonConversion_Success(t *testing.T) {
521
+ cb := &mockCallbacks{}
522
+ h := newTestHandler(cb)
523
+
524
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
525
+ h.seen.Add(oldCfg)
526
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
527
+
528
+ // ParseAndValidate returns config with different hash.
529
+ cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
530
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
531
+ }
532
+
533
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
534
+ h.CmdUpdate(fn)
535
+
536
+ // Should call Update (not Stop+Start).
537
+ assert.Len(t, cb.updateCalls, 1)
538
+ assert.Len(t, cb.stopCalls, 0)
539
+ assert.Len(t, cb.startCalls, 0)
540
+
541
+ entry, ok := h.exposed.LookupByKey("job1")
542
+ require.True(t, ok)
543
+ assert.Equal(t, StatusRunning, entry.Status)
544
+ assert.Equal(t, uint64(200), entry.Cfg.Hash())
545
+}
546
+
547
+func TestCmdUpdate_NonConversion_NoOp(t *testing.T) {
548
+ cb := &mockCallbacks{}
549
+ h := newTestHandler(cb)
550
+
551
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
552
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
553
+
554
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
555
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}, nil
556
+ }
557
+
558
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
559
+ h.CmdUpdate(fn)
560
+
561
+ // No-op: same hash, running, not conversion.
562
+ assert.Len(t, cb.updateCalls, 0)
563
+ assert.Len(t, cb.stopCalls, 0)
564
+ assert.Len(t, cb.startCalls, 0)
565
+}
566
+
567
+func TestCmdUpdate_Conversion_Success(t *testing.T) {
568
+ cb := &mockCallbacks{}
569
+ h := newTestHandler(cb)
570
+
571
+ oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
572
+ h.seen.Add(oldCfg)
573
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
574
+
575
+ cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
576
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
577
+ }
578
+
579
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
580
+ h.CmdUpdate(fn)
581
+
582
+ // Conversion uses Stop + Start, not Update.
583
+ assert.Len(t, cb.stopCalls, 1)
584
+ assert.Len(t, cb.startCalls, 1)
585
+ assert.Len(t, cb.updateCalls, 0)
586
+
587
+ entry, _ := h.exposed.LookupByKey("job1")
588
+ assert.Equal(t, StatusRunning, entry.Status)
589
+ assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
590
+
591
+ // Old stock config should still be in seen (for re-promotion).
592
+ _, ok := h.seen.LookupByUID("stock:job1")
593
+ assert.True(t, ok, "stock config should stay in seen for conversion")
594
+}
595
+
596
+func TestCmdUpdate_Disabled_PreservesStatus(t *testing.T) {
597
+ cb := &mockCallbacks{}
598
+ h := newTestHandler(cb)
599
+
600
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
601
+ h.seen.Add(oldCfg)
602
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
603
+
604
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
605
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
606
+ }
607
+
608
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
609
+ h.CmdUpdate(fn)
610
+
611
+ // Should NOT start, should preserve Disabled.
612
+ assert.Len(t, cb.startCalls, 0)
613
+ assert.Len(t, cb.updateCalls, 0)
614
+
615
+ entry, _ := h.exposed.LookupByKey("job1")
616
+ assert.Equal(t, StatusDisabled, entry.Status)
617
+}
618
+
619
+func TestCmdUpdate_Accepted_Rejected(t *testing.T) {
620
+ cb := &mockCallbacks{}
621
+ h := newTestHandler(cb)
622
+
623
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
624
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusAccepted})
625
+
626
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
627
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
628
+ }
629
+
630
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
631
+ h.CmdUpdate(fn)
632
+
633
+ // Accepted configs can't be updated.
634
+ assert.Len(t, cb.updateCalls, 0)
635
+ assert.Len(t, cb.startCalls, 0)
636
+
637
+ entry, _ := h.exposed.LookupByKey("job1")
638
+ assert.Equal(t, StatusAccepted, entry.Status)
639
+}
640
+
641
+func TestCmdUpdate_NotFound(t *testing.T) {
642
+ cb := &mockCallbacks{}
643
+ h := newTestHandler(cb)
644
+
645
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
646
+ h.CmdUpdate(fn)
647
+
648
+ assert.Len(t, cb.updateCalls, 0)
649
+}
650
+
651
+func TestCmdUpdate_ParseError(t *testing.T) {
652
+ cb := &mockCallbacks{}
653
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
654
+ return testConfig{}, errors.New("bad config")
655
+ }
656
+ h := newTestHandler(cb)
657
+
658
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
659
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
660
+
661
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
662
+ h.CmdUpdate(fn)
663
+
664
+ // Parse error should not modify cache.
665
+ entry, _ := h.exposed.LookupByKey("job1")
666
+ assert.Equal(t, StatusRunning, entry.Status)
667
+}
668
+
669
+func TestCmdUpdate_NonConversion_StartFails(t *testing.T) {
670
+ cb := &mockCallbacks{}
671
+ cb.updateFn = func(_, _ testConfig) error { return errors.New("update failed") }
672
+ h := newTestHandler(cb)
673
+
674
+ oldCfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 100}
675
+ h.seen.Add(oldCfg)
676
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
677
+
678
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
679
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200}, nil
680
+ }
681
+
682
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
683
+ h.CmdUpdate(fn)
684
+
685
+ entry, _ := h.exposed.LookupByKey("job1")
686
+ assert.Equal(t, StatusFailed, entry.Status)
687
+}
688
+
689
+func TestCmdUpdate_Conversion_StartFails(t *testing.T) {
690
+ cb := &mockCallbacks{}
691
+ cb.startFn = func(_ testConfig) error { return errors.New("start failed") }
692
+ h := newTestHandler(cb)
693
+
694
+ oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock", hash: 100}
695
+ h.seen.Add(oldCfg)
696
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusRunning})
697
+
698
+ cb.parseAndValidateFn = func(_ Function, name string) (testConfig, error) {
699
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", hash: 200, source: "test"}, nil
700
+ }
701
+
702
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
703
+ h.CmdUpdate(fn)
704
+
705
+ // Conversion uses Stop + Start; Start fails → Failed status.
706
+ assert.Len(t, cb.stopCalls, 1)
707
+ assert.Len(t, cb.startCalls, 1)
708
+
709
+ entry, _ := h.exposed.LookupByKey("job1")
710
+ assert.Equal(t, StatusFailed, entry.Status)
711
+ assert.Equal(t, "dyncfg", entry.Cfg.SourceType())
712
+}
713
+
714
+func TestCmdUpdate_NoPayload(t *testing.T) {
715
+ cb := &mockCallbacks{}
716
+ h := newTestHandler(cb)
717
+
718
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
719
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
720
+
721
+ // No payload (nil).
722
+ fn := newTestFn("test:job1", "update", "job1", nil)
723
+ h.CmdUpdate(fn)
724
+
725
+ // Should fail with missing payload, not modify cache.
726
+ assert.Len(t, cb.updateCalls, 0)
727
+ entry, _ := h.exposed.LookupByKey("job1")
728
+ assert.Equal(t, StatusRunning, entry.Status)
729
+}
730
+
731
+func TestCmdUpdate_Conversion_Disabled(t *testing.T) {
732
+ cb := &mockCallbacks{}
733
+ h := newTestHandler(cb)
734
+
735
+ oldCfg := testConfig{uid: "stock:job1", key: "job1", sourceType: "stock"}
736
+ h.exposed.Add(&Entry[testConfig]{Cfg: oldCfg, Status: StatusDisabled})
737
+
738
+ cb.parseAndValidateFn = func(_ Function, _ string) (testConfig, error) {
739
+ return testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg", source: "test"}, nil
740
+ }
741
+
742
+ fn := newTestFn("test:job1", "update", "job1", []byte(`{}`))
743
+ h.CmdUpdate(fn)
744
+
745
+ // Conversion with Disabled: Stop old, update caches, preserve Disabled.
746
+ assert.Len(t, cb.stopCalls, 1)
747
+ assert.Len(t, cb.startCalls, 0)
748
+
749
+ entry, _ := h.exposed.LookupByKey("job1")
750
+ assert.Equal(t, StatusDisabled, entry.Status)
751
+}
752
+
753
+// --- CmdRestart Tests ---
754
+
755
+func TestCmdRestart_FromRunning(t *testing.T) {
756
+ cb := &mockCallbacks{}
757
+ h := newTestHandler(cb)
758
+
759
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
760
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
761
+
762
+ fn := newTestFn("test:job1", "restart", "", nil)
763
+ h.CmdRestart(fn)
764
+
765
+ assert.Len(t, cb.stopCalls, 1)
766
+ assert.Len(t, cb.startCalls, 1)
767
+
768
+ entry, _ := h.exposed.LookupByKey("job1")
769
+ assert.Equal(t, StatusRunning, entry.Status)
770
+ require.Len(t, cb.statusCalls, 1)
771
+ assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
772
+}
773
+
774
+func TestCmdRestart_FromFailed(t *testing.T) {
775
+ cb := &mockCallbacks{}
776
+ h := newTestHandler(cb)
777
+
778
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
779
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusFailed})
780
+
781
+ fn := newTestFn("test:job1", "restart", "", nil)
782
+ h.CmdRestart(fn)
783
+
784
+ assert.Len(t, cb.stopCalls, 1)
785
+ assert.Len(t, cb.startCalls, 1)
786
+
787
+ entry, _ := h.exposed.LookupByKey("job1")
788
+ assert.Equal(t, StatusRunning, entry.Status)
789
+ require.Len(t, cb.statusCalls, 1)
790
+ assert.Equal(t, StatusFailed, cb.statusCalls[0].oldStatus)
791
+}
792
+
793
+func TestCmdRestart_Accepted_Rejected(t *testing.T) {
794
+ cb := &mockCallbacks{}
795
+ h := newTestHandler(cb)
796
+
797
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
798
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusAccepted})
799
+
800
+ fn := newTestFn("test:job1", "restart", "", nil)
801
+ h.CmdRestart(fn)
802
+
803
+ assert.Len(t, cb.stopCalls, 0)
804
+ assert.Len(t, cb.startCalls, 0)
805
+
806
+ entry, _ := h.exposed.LookupByKey("job1")
807
+ assert.Equal(t, StatusAccepted, entry.Status)
808
+}
809
+
810
+func TestCmdRestart_Disabled_Rejected(t *testing.T) {
811
+ cb := &mockCallbacks{}
812
+ h := newTestHandler(cb)
813
+
814
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
815
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusDisabled})
816
+
817
+ fn := newTestFn("test:job1", "restart", "", nil)
818
+ h.CmdRestart(fn)
819
+
820
+ assert.Len(t, cb.stopCalls, 0)
821
+ assert.Len(t, cb.startCalls, 0)
822
+
823
+ entry, _ := h.exposed.LookupByKey("job1")
824
+ assert.Equal(t, StatusDisabled, entry.Status)
825
+}
826
+
827
+func TestCmdRestart_NotFound(t *testing.T) {
828
+ cb := &mockCallbacks{}
829
+ h := newTestHandler(cb)
830
+
831
+ fn := newTestFn("test:job1", "restart", "", nil)
832
+ h.CmdRestart(fn)
833
+
834
+ assert.Len(t, cb.stopCalls, 0)
835
+ assert.Len(t, cb.startCalls, 0)
836
+}
837
+
838
+func TestCmdRestart_StartFails(t *testing.T) {
839
+ cb := &mockCallbacks{}
840
+ cb.startFn = func(_ testConfig) error { return errors.New("restart failed") }
841
+ h := newTestHandler(cb)
842
+
843
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
844
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
845
+
846
+ fn := newTestFn("test:job1", "restart", "", nil)
847
+ h.CmdRestart(fn)
848
+
849
+ assert.Len(t, cb.stopCalls, 1)
850
+ assert.Len(t, cb.startCalls, 1)
851
+
852
+ entry, _ := h.exposed.LookupByKey("job1")
853
+ assert.Equal(t, StatusFailed, entry.Status)
854
+ require.Len(t, cb.statusCalls, 1)
855
+ assert.Equal(t, StatusRunning, cb.statusCalls[0].oldStatus)
856
+}
857
+
858
+func TestCmdRestart_StartFails_CodedError(t *testing.T) {
859
+ cb := &mockCallbacks{}
860
+ cb.startFn = func(_ testConfig) error {
861
+ return &codedErr{err: errors.New("bad config"), code: 400}
862
+ }
863
+ h := newTestHandler(cb)
864
+
865
+ cfg := testConfig{uid: "dyncfg:job1", key: "job1", sourceType: "dyncfg"}
866
+ h.exposed.Add(&Entry[testConfig]{Cfg: cfg, Status: StatusRunning})
867
+
868
+ fn := newTestFn("test:job1", "restart", "", nil)
869
+ h.CmdRestart(fn)
870
+
871
+ entry, _ := h.exposed.LookupByKey("job1")
872
+ assert.Equal(t, StatusFailed, entry.Status)
873
+}
874
+
875
+// --- Notify Tests ---
876
+
877
+func TestNotifyJobCreate_SupportedCommands(t *testing.T) {
878
+ tests := []struct {
879
+ name string
880
+ commands []Command
881
+ sourceType string
882
+ wantRemove bool
883
+ }{
884
+ {"dyncfg with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "dyncfg", true},
885
+ {"dyncfg no restart", []Command{CommandSchema, CommandGet}, "dyncfg", true},
886
+ {"stock with restart", []Command{CommandSchema, CommandGet, CommandRestart}, "stock", false},
887
+ {"stock no restart", []Command{CommandSchema, CommandGet}, "stock", false},
888
+ }
889
+
890
+ for _, tt := range tests {
891
+ t.Run(tt.name, func(t *testing.T) {
892
+ cb := &mockCallbacks{}
893
+ h := newTestHandler(cb)
894
+ h.jobCommands = tt.commands
895
+
896
+ cmds := h.jobSupportedCommands(tt.sourceType == "dyncfg")
897
+
898
+ // Base commands should always be present.
899
+ for _, cmd := range tt.commands {
900
+ assert.Contains(t, cmds, string(cmd))
901
+ }
902
+ if tt.wantRemove {
903
+ assert.Contains(t, cmds, "remove")
904
+ } else {
905
+ assert.NotContains(t, cmds, "remove")
906
+ }
907
+ })
908
+ }
909
+}
910
+
911
+// --- ValidateJobName Tests ---
912
+
913
+func TestValidateJobName(t *testing.T) {
914
+ tests := []struct {
915
+ name string
916
+ input string
917
+ wantErr bool
918
+ }{
919
+ {"valid", "my_job", false},
920
+ {"valid with numbers", "job123", false},
921
+ {"valid with dashes", "my-job", false},
922
+ {"space", "my job", true},
923
+ {"tab", "my\tjob", true},
924
+ {"dot", "my.job", true},
925
+ {"colon", "my:job", true},
926
+ {"empty", "", false},
927
+ }
928
+
929
+ for _, tt := range tests {
930
+ t.Run(tt.name, func(t *testing.T) {
931
+ err := ValidateJobName(tt.input)
932
+ if tt.wantErr {
933
+ assert.Error(t, err, fmt.Sprintf("ValidateJobName(%q) should fail", tt.input))
934
+ } else {
935
+ assert.NoError(t, err, fmt.Sprintf("ValidateJobName(%q) should pass", tt.input))
936
+ }
937
+ })
938
+ }
939
+}