package desired // S3 Group C — the raw-consumer fan-out seam: called on generation advance, NOT on no-advance, // and a panicking consumer is contained (the guest reconcile path must never break). import ( "context" "io" "log/slog" "sync" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" ) type recordingConsumer struct { mu sync.Mutex calls []*hub.DesiredStateResponse panic bool } func (r *recordingConsumer) OnDesiredState(_ context.Context, resp *hub.DesiredStateResponse) { r.mu.Lock() r.calls = append(r.calls, resp) r.mu.Unlock() if r.panic { panic("consumer exploded") } } func (r *recordingConsumer) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.calls) } type stubFetcher struct{ resp *hub.DesiredStateResponse } func (s *stubFetcher) FetchDesiredState(context.Context) (*hub.DesiredStateResponse, error) { return s.resp, nil } func testResp(gen int64) *hub.DesiredStateResponse { return &hub.DesiredStateResponse{ Generation: gen, DesiredState: hub.WireDesiredState{ Guests: []hub.WireDesiredGuest{}, Wireguard: &hub.WireWireguard{Pubkey: "PK", AssignedIP: "10.77.0.2/32"}, }, } } func TestSyncer_ConsumerCalledOnAdvanceOnly(t *testing.T) { provider := reconcile.NewCachingProvider() f := &stubFetcher{resp: testResp(2)} s := NewSyncer(f, provider, slog.New(slog.NewTextHandler(io.Discard, nil))) c := &recordingConsumer{} s.AddConsumer(c) // Advance → fetch → consumer called with the raw doc (wireguard block intact). s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 2}) if c.count() != 1 { t.Fatalf("consumer calls = %d, want 1", c.count()) } if c.calls[0].DesiredState.Wireguard == nil || c.calls[0].DesiredState.Wireguard.Pubkey != "PK" { t.Fatalf("consumer got %+v — the raw wireguard block must ride through", c.calls[0].DesiredState.Wireguard) } // No advance → no fetch → no consumer call (the negative). s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 2}) if c.count() != 1 { t.Errorf("consumer called without a generation advance: %d", c.count()) } } func TestSyncer_PanickingConsumerContained(t *testing.T) { provider := reconcile.NewCachingProvider() f := &stubFetcher{resp: testResp(1)} s := NewSyncer(f, provider, slog.New(slog.NewTextHandler(io.Discard, nil))) bomb := &recordingConsumer{panic: true} after := &recordingConsumer{} s.AddConsumer(bomb) s.AddConsumer(after) // Must not panic out; the second consumer still runs; the provider still updated. s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 1}) if after.count() != 1 { t.Errorf("consumer after the panicking one not called: %d", after.count()) } if provider.Generation() != 1 { t.Errorf("provider generation = %d, want 1 (guest path unaffected)", provider.Generation()) } }