package entities import ( "context" "errors" "io" "log/slog" "sync/atomic" "testing" "time" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" ) func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func TestRefresherStart(t *testing.T) { t.Run("happy path populates resolver before returning", func(t *testing.T) { fake := &fakeEntityServiceClient{ listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) { return &hav1.ListStatesResponse{States: []*hav1.EntityState{ {EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}}, }}, nil }, } client := NewClient(fake, []string{"light"}) resolver := NewResolver() r := NewRefresher(client, resolver, time.Hour, discardLogger()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := r.Start(ctx); err != nil { t.Fatalf("Start() error = %v", err) } if _, ok := resolver.Resolve("Living Room Lamp"); !ok { t.Fatal("resolver not populated after Start() returned") } }) t.Run("initial fetch failure returns error and leaves resolver empty", func(t *testing.T) { wantErr := errors.New("ha-gateway unreachable") fake := &fakeEntityServiceClient{ listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) { return nil, wantErr }, } client := NewClient(fake, []string{"light"}) resolver := NewResolver() r := NewRefresher(client, resolver, time.Hour, discardLogger()) err := r.Start(context.Background()) if !errors.Is(err, wantErr) { t.Fatalf("Start() error = %v, want it to wrap %v", err, wantErr) } if _, ok := resolver.Resolve("anything"); ok { t.Fatal("resolver should stay empty when the initial fetch fails") } }) } func TestRefresherPeriodicRefresh(t *testing.T) { var calls int32 called := make(chan struct{}, 16) fake := &fakeEntityServiceClient{ listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) { atomic.AddInt32(&calls, 1) select { case called <- struct{}{}: default: } return &hav1.ListStatesResponse{States: []*hav1.EntityState{ {EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}}, }}, nil }, } client := NewClient(fake, []string{"light"}) resolver := NewResolver() r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := r.Start(ctx); err != nil { t.Fatalf("Start() error = %v", err) } // The initial blocking fetch inside Start already counts as one call; // wait for at least two more from the background ticker loop. timeout := time.After(2 * time.Second) for i := 0; i < 2; i++ { select { case <-called: case <-timeout: t.Fatal("timed out waiting for periodic refresh") } } if atomic.LoadInt32(&calls) < 2 { t.Fatalf("calls = %d, want at least 2", calls) } } func TestRefresherPeriodicRefreshFailureDoesNotStopLoop(t *testing.T) { var calls int32 called := make(chan struct{}, 16) fake := &fakeEntityServiceClient{ listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) { n := atomic.AddInt32(&calls, 1) select { case called <- struct{}{}: default: } // Fail the initial fetch's implied call (n==1 is consumed by // Start synchronously below, so only the periodic ones matter // here); alternate failures thereafter to prove one bad // refresh doesn't wedge the loop. if n%2 == 0 { return nil, errors.New("transient ha-gateway error") } return &hav1.ListStatesResponse{States: []*hav1.EntityState{ {EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}}, }}, nil }, } client := NewClient(fake, []string{"light"}) resolver := NewResolver() r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := r.Start(ctx); err != nil { t.Fatalf("Start() error = %v", err) } timeout := time.After(2 * time.Second) for i := 0; i < 3; i++ { select { case <-called: case <-timeout: t.Fatal("timed out waiting for periodic refresh") } } if atomic.LoadInt32(&calls) < 3 { t.Fatalf("calls = %d, want at least 3 (loop should keep running through failures)", calls) } // The resolver should still hold whatever the last successful refresh // produced, even though some refreshes in between failed. if _, ok := resolver.Resolve("Living Room Lamp"); !ok { t.Fatal("resolver lost its last-known-good data after a failed periodic refresh") } }