package desired import ( "context" "errors" "io" "log/slog" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" ) func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // fakeFetcher counts FetchDesiredState calls and returns a fixed response (or error). type fakeFetcher struct { resp *hub.DesiredStateResponse err error calls int } func (f *fakeFetcher) FetchDesiredState(context.Context) (*hub.DesiredStateResponse, error) { f.calls++ return f.resp, f.err } func env(gen int64, signed bool) *hub.ControlEnvelope { return &hub.ControlEnvelope{DesiredGeneration: gen, HasSignedOps: signed} } // The headline caching behaviour: desired-state is fetched ONCE when the generation advances, and // NOT re-fetched while the generation is unchanged (the heartbeat stays light). func TestSyncer_FetchesOnceOnGenerationAdvance(t *testing.T) { run := "running" f := &fakeFetcher{resp: &hub.DesiredStateResponse{ Generation: 1, DesiredState: hub.WireDesiredState{Guests: []hub.WireDesiredGuest{ {VMID: 100, Run: run}, {VMID: 200, Decommission: true}, }}, }} p := reconcile.NewCachingProvider() s := NewSyncer(f, p, quiet()) ctx := context.Background() // Generation 0 (fresh host, no intent) → NO fetch. s.OnEnvelope(ctx, env(0, false)) if f.calls != 0 { t.Fatalf("fetched %d times at generation 0, want 0", f.calls) } // Generation advances to 1 → fetch exactly once, cache updated. s.OnEnvelope(ctx, env(1, false)) if f.calls != 1 { t.Fatalf("fetched %d times on advance, want 1", f.calls) } if p.Generation() != 1 { t.Errorf("cached generation = %d, want 1", p.Generation()) } st, _ := p.Desired(ctx) if st.Guests[100].Run != reconcile.RunRunning { t.Errorf("guest 100 run = %q, want running", st.Guests[100].Run) } if !st.Guests[200].Decommission { t.Errorf("guest 200 decommission = false, want true") } // Same generation on the next heartbeats → NO re-fetch (cached). s.OnEnvelope(ctx, env(1, false)) s.OnEnvelope(ctx, env(1, false)) if f.calls != 1 { t.Errorf("re-fetched on an unchanged generation (calls=%d, want 1)", f.calls) } // A further advance → one more fetch. f.resp = &hub.DesiredStateResponse{Generation: 2, DesiredState: hub.WireDesiredState{}} s.OnEnvelope(ctx, env(2, false)) if f.calls != 2 || p.Generation() != 2 { t.Errorf("second advance: calls=%d gen=%d, want 2/2", f.calls, p.Generation()) } } // A fetch failure keeps the last-known cache (the engine keeps reconciling toward it) and does not // advance the cached generation (so the next heartbeat retries). func TestSyncer_FetchFailureKeepsCache(t *testing.T) { p := reconcile.NewCachingProvider() p.Update(1, reconcile.DesiredState{Guests: map[int]reconcile.DesiredGuest{100: {VMID: 100, Run: reconcile.RunRunning}}}) f := &fakeFetcher{err: errors.New("hub down")} s := NewSyncer(f, p, quiet()) s.OnEnvelope(context.Background(), env(5, false)) // generation jumped, but fetch fails if p.Generation() != 1 { t.Errorf("generation advanced to %d despite fetch failure, want kept at 1", p.Generation()) } st, _ := p.Desired(context.Background()) if st.Guests[100].Run != reconcile.RunRunning { t.Errorf("cache lost on fetch failure: %+v", st.Guests) } } // The fetched generation (not the envelope's) is what gets cached — robust to a generation that // advanced again between the heartbeat and the fetch. func TestSyncer_CachesFetchedGeneration(t *testing.T) { f := &fakeFetcher{resp: &hub.DesiredStateResponse{Generation: 7, DesiredState: hub.WireDesiredState{}}} p := reconcile.NewCachingProvider() s := NewSyncer(f, p, quiet()) s.OnEnvelope(context.Background(), env(5, false)) // envelope said 5, fetch returned 7 if p.Generation() != 7 { t.Errorf("cached generation = %d, want 7 (the fetched generation)", p.Generation()) } // A later envelope at generation 6 must NOT trigger a re-fetch (we already have 7). s.OnEnvelope(context.Background(), env(6, false)) if f.calls != 1 { t.Errorf("re-fetched at generation 6 when cache is 7 (calls=%d)", f.calls) } }