From 8df105893235fcd74a27cc57b05d22c1cc0d3343 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Tue, 16 Jun 2026 13:34:55 +0200 Subject: [PATCH] test(api): cover everyTick helper (#52) * test(api): cover everyTick helper * test(api): make everyTick cancel test deterministic --- api/main_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 api/main_test.go diff --git a/api/main_test.go b/api/main_test.go new file mode 100644 index 0000000..1ff3637 --- /dev/null +++ b/api/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its +// interval. A non-blocking send keeps the spawned goroutine from ever blocking +// on a full channel, and the 1s timeout per tick is generous vs the 1ms +// interval so the test is not timing-flaky. +func TestEveryTick_CallsFnOnTick(t *testing.T) { + ctx := t.Context() + + fired := make(chan struct{}, 1) + everyTick(ctx, time.Millisecond, func(context.Context) { + select { + case fired <- struct{}{}: + default: + } + }) + + for i := range 3 { + select { + case <-fired: + case <-time.After(time.Second): + t.Fatalf("tick %d did not fire within 1s", i+1) + } + } +} + +// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop. +// After the first tick we cancel inside the inter-tick gap, so everyTick's +// select sees only ctx.Done ready (ticker.C is cold) and returns +// deterministically — there is no random pick between two ready cases. We then +// confirm no further tick arrives within a full interval, which a live loop +// could not satisfy. This is the shutdown guarantee the reconcilers depend on. +func TestEveryTick_StopsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + const interval = 50 * time.Millisecond + ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives + everyTick(ctx, interval, func(context.Context) { + ticks <- struct{}{} + }) + + // First tick proves the loop is running and leaves the goroutine back in + // select with ticker.C cold for the next ~interval. + select { + case <-ticks: + case <-time.After(time.Second): + t.Fatal("everyTick never called fn") + } + + cancel() + + // Cancel landed in the cold gap, so the loop must have taken ctx.Done and + // returned. A live loop would tick again at ~interval; a stopped one never + // will. + select { + case <-ticks: + t.Fatal("everyTick ticked again after cancel") + case <-time.After(interval + 50*time.Millisecond): + // good: silent past a full interval — the loop returned + } +} + +func TestEveryTick_PassesContextToFn(t *testing.T) { + type ctxKey string + const marker ctxKey = "marker" + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present")) + defer cancel() + + got := make(chan string, 1) + everyTick(ctx, time.Millisecond, func(c context.Context) { + v, _ := c.Value(marker).(string) + select { + case got <- v: + default: + } + }) + + select { + case v := <-got: + if v != "present" { + t.Fatalf("fn received wrong context: value = %q, want %q", v, "present") + } + case <-time.After(time.Second): + t.Fatalf("fn never ran") + } +}