Respect provider rate limits and preserve bank connections on throttling
This commit is contained in:
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/classification"
|
||||
@@ -86,6 +89,90 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
before := domain.Clone(s.Data)
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Retry-After", "300")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer provider.Close()
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, model := range []string{"test/model", "test/another-model"} {
|
||||
preview, err := a.Preview(ctx, PreviewRequest{
|
||||
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
|
||||
Model: model, Fields: Fields{Category: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.Analysed != 2 || len(preview.Errors) != 2 || len(preview.Changes) != 0 {
|
||||
t.Fatalf("rate-limited preview did not preserve both records: %+v", preview)
|
||||
}
|
||||
}
|
||||
unchanged, err := a.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, unchanged.Data) {
|
||||
t.Fatal("rate-limited previews changed canonical data")
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
result, err := a.importFacts(ctx, unchanged, []domain.Facts{sampleFacts("ALDI", "2026-09-10", "-12.34")})
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 1 || len(result.State.Data.Transactions) != 3 {
|
||||
t.Fatal("provider cooldown lost the newly imported record")
|
||||
}
|
||||
for _, tx := range result.State.Data.Transactions {
|
||||
if tx.Facts.ExternalID == hex.EncodeToString([]byte("ALDI")) {
|
||||
if tx.Enrichment.Classification.Error == "" || tx.Enrichment.CategoryID != domain.ExpenseFallback {
|
||||
t.Fatal("cooldown did not leave imported facts editable and unclassified")
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("previews and imports bypassed shared provider cooldown: %d requests", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
cancel()
|
||||
}))
|
||||
defer provider.Close()
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
||||
p, err := a.Preview(ctx, PreviewRequest{
|
||||
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
|
||||
Model: "test/model", Fields: Fields{Category: true},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || p.ID != "" {
|
||||
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(s.Data, after.Data) {
|
||||
t.Fatal("cancelled preview changed canonical data")
|
||||
}
|
||||
}
|
||||
func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) {
|
||||
t.Helper()
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user