Ctrl K
ComunidadGuías…

tariff/helper_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package tariff

import (
	"errors"
	"net/http"
	"net/url"
	"sync"
	"testing"
	"time"

	"github.com/benbjohnson/clock"
	"github.com/cenkalti/backoff/v4"
	"github.com/evcc-io/evcc/api"
	"github.com/evcc-io/evcc/util"
	"github.com/evcc-io/evcc/util/request"
	"github.com/jinzhu/now"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestMergeRatesAfter(t *testing.T) {
	clock := clock.NewMock()
	clock.Set(now.BeginningOfDay())

	rate := func(start int, val float64) api.Rate {
		return api.Rate{
			Start: clock.Now().Add(time.Duration(start) * time.Hour),
			End:   clock.Now().Add(time.Duration(start+1) * time.Hour),
			Value: val,
		}
	}

	old := api.Rates{rate(1, 1), rate(2, 2)}
	new := api.Rates{rate(2, 2), rate(3, 3)}
	combined := api.Rates{rate(1, 1), rate(2, 2), rate(3, 3)}

	data := util.NewMonitor[api.Rates](time.Hour).WithClock(clock)

	data.Set(old)
	res, err := data.Get()
	require.NoError(t, err)
	assert.Equal(t, old, res)

	for i, tc := range []struct {
		new, expected api.Rates
		ts            time.Time
	}{
		{new, combined, clock.Now()},
		{new, combined, clock.Now().Add(SlotDuration)},
		{nil, combined, clock.Now()},
		{nil, combined, clock.Now().Add(SlotDuration)},
		{new, combined, clock.Now().Add(time.Hour)},
		{new, combined, clock.Now().Add(time.Hour + SlotDuration)},
		{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour()},
		{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour().Add(SlotDuration)},
		{new, new, clock.Now().Add(2 * time.Hour)},
		{new, new, clock.Now().Add(2*time.Hour + SlotDuration)},
	} {
		t.Logf("%d. %+v", i+1, tc)

		mergeRatesAfter(data, tc.new, tc.ts)

		res, err := data.Get()
		require.NoError(t, err)
		assert.Equal(t, tc.expected, res)
	}
}

// TestMergeRatesAfterRefreshesTimestamp asserts that merging without new rates
// keeps the cached rates and un-expires the monitor - this is how solcast keeps
// its forecast alive while fetching is paused outside the from/to window.
func TestMergeRatesAfterRefreshesTimestamp(t *testing.T) {
	clock := clock.NewMock()
	clock.Set(now.BeginningOfDay())

	rates := api.Rates{{
		Start: clock.Now().Add(time.Hour),
		End:   clock.Now().Add(2 * time.Hour),
		Value: 1,
	}}

	data := util.NewMonitor[api.Rates](time.Hour).WithClock(clock)
	data.Set(rates)

	clock.Add(2 * time.Hour)
	_, err := data.Get()
	require.ErrorIs(t, err, api.ErrOutdated)

	mergeRatesAfter(data, nil, clock.Now().Add(-2*time.Hour))

	res, err := data.Get()
	require.NoError(t, err)
	assert.Equal(t, rates, res)
}

type runner struct {
	res error
}

func (r *runner) run(done chan error) {
	if r.res == nil {
		close(done)
	} else {
		done <- r.res
	}
}

func TestRunOrQError(t *testing.T) {
	{
		res, err := runOrError(&runner{nil})
		require.NoError(t, err)
		require.NotNil(t, res)
	}
	{
		res, err := runOrError(&runner{errors.New("foo")})
		require.Error(t, err)
		require.Nil(t, res)
	}
}

// leakRunner mirrors the real tariff run loop: its first update always fails
// (e.g. HTTP 429 at startup) and it reports the error via reportError. With the
// fix in place reportError returns true and run returns; without it the loop
// would block on the hourly tick and keep polling forever.
type leakRunner struct {
	exited chan struct{}
}

func (r *leakRunner) run(done chan error) {
	defer close(r.exited)

	var once sync.Once
	for tick := time.Tick(time.Hour); ; <-tick {
		if reportError(&once, done, errors.New("initial failure (e.g. HTTP 429)")) {
			return
		}
		// without the fix the goroutine would block on <-tick here and keep
		// hitting the API on every tick - exactly the leak we guard against
	}
}

// TestRunOrErrorStopsGoroutineOnStartupFailure asserts that when the first
// update fails, runOrError both propagates the error and stops the background
// goroutine instead of leaving it to poll the API indefinitely.
func TestRunOrErrorStopsGoroutineOnStartupFailure(t *testing.T) {
	r := &leakRunner{exited: make(chan struct{})}

	res, err := runOrError(r)
	require.Error(t, err, "startup failure must be propagated")
	require.Nil(t, res, "tariff must not be returned when startup fails")

	select {
	case <-r.exited:
		// goroutine returned - no leak
	case <-time.After(time.Second):
		t.Fatal("run goroutine still alive after startup failure (goroutine leak)")
	}
}

func TestBackoffPermanentError(t *testing.T) {
	for _, tc := range []struct {
		code      int
		permanent bool
	}{
		{http.StatusBadRequest, true},
		{http.StatusInternalServerError, true},
		{http.StatusTooManyRequests, false},
	} {
		// status errors arrive wrapped as permanent from the request package
		err := backoff.Permanent(request.NewStatusError(&http.Response{StatusCode: tc.code, Request: &http.Request{Method: http.MethodGet, URL: &url.URL{}}}))

		var perm *backoff.PermanentError
		assert.Equal(t, tc.permanent, errors.As(backoffPermanentError(err), &perm), "status %d", tc.code)
	}
}