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) {
|
||||
|
||||
@@ -22,10 +22,10 @@ type backfillBank struct {
|
||||
fetchErr error
|
||||
}
|
||||
|
||||
func (b *backfillBank) Status(ctx context.Context, id string) (banking.Session, error) {
|
||||
func (b *backfillBank) Status(ctx context.Context, id string) (banking.SessionStatus, error) {
|
||||
b.statusIDs = append(b.statusIDs, id)
|
||||
if b.statusErr != nil {
|
||||
return banking.Session{}, b.statusErr
|
||||
return banking.SessionStatus{}, b.statusErr
|
||||
}
|
||||
return b.historyBank.Status(ctx, id)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestBackfillRejectsUnsafePrerequisitesBeforeProviderContact(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackfillProviderFailuresDoNotImportPartialData(t *testing.T) {
|
||||
for _, scenario := range []string{"not configured", "unavailable", "revoked", "provider expired", "account absent", "partial retrieval"} {
|
||||
for _, scenario := range []string{"not configured", "unavailable", "revoked", "provider expired", "account absent", "partial retrieval", "status rate limit", "retrieval rate limit"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
a, s, b := backfillApp(t)
|
||||
s = seed(t, a, s)
|
||||
@@ -221,6 +221,10 @@ func TestBackfillProviderFailuresDoNotImportPartialData(t *testing.T) {
|
||||
b.session.Accounts = []domain.Account{{ID: s.Data.Accounts[0].ID, ExternalAccountID: "wrong_uid"}}
|
||||
case "partial retrieval":
|
||||
b.fetchErr = errors.New("private provider response")
|
||||
case "status rate limit":
|
||||
b.statusErr = bankRateError(t)
|
||||
case "retrieval rate limit":
|
||||
b.fetchErr = bankRateError(t)
|
||||
}
|
||||
beforeOps, err := json.Marshal(a.ops)
|
||||
if err != nil {
|
||||
@@ -233,7 +237,10 @@ func TestBackfillProviderFailuresDoNotImportPartialData(t *testing.T) {
|
||||
if strings.Contains(err.Error(), "private provider response") {
|
||||
t.Fatal("provider error exposed private response data")
|
||||
}
|
||||
if scenario != "partial retrieval" && len(b.accounts) != 0 {
|
||||
if strings.Contains(scenario, "rate limit") && (!strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "retry") || errors.Is(err, banking.ErrReconnect)) {
|
||||
t.Fatal("backfill obscured rate limiting or falsely required reconnect")
|
||||
}
|
||||
if scenario != "partial retrieval" && scenario != "retrieval rate limit" && len(b.accounts) != 0 {
|
||||
t.Fatal("unavailable consent reached transaction retrieval")
|
||||
}
|
||||
current, err := a.Snapshot(context.Background())
|
||||
|
||||
@@ -185,11 +185,11 @@ func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
|
||||
|
||||
type recoveryBank struct{ historyBank }
|
||||
|
||||
func (b *recoveryBank) Status(ctx context.Context, id string) (banking.Session, error) {
|
||||
func (b *recoveryBank) Status(ctx context.Context, id string) (banking.SessionStatus, error) {
|
||||
if id != b.session.ID {
|
||||
return banking.Session{}, banking.ErrReconnect
|
||||
return banking.SessionStatus{}, banking.ErrReconnect
|
||||
}
|
||||
return b.session, nil
|
||||
return b.historyBank.Status(ctx, id)
|
||||
}
|
||||
|
||||
func TestInterruptedRenewalDiscardsSupersededConsentDuringRecovery(t *testing.T) {
|
||||
|
||||
+47
-19
@@ -12,6 +12,7 @@ import (
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/classification"
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/ratelimit"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
@@ -136,27 +137,19 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths
|
||||
}
|
||||
current, err := a.bank.Status(ctx, session.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, banking.ErrReconnect) {
|
||||
return ImportResult{}, banking.ErrReconnect
|
||||
}
|
||||
return ImportResult{}, errors.New("bank connection unavailable; retry importing history")
|
||||
return ImportResult{}, bankFailure(err, "bank connection unavailable; retry importing history")
|
||||
}
|
||||
expiry, err = time.Parse(time.RFC3339, current.ValidUntil)
|
||||
if err != nil || !expiry.After(time.Now()) {
|
||||
return ImportResult{}, banking.ErrReconnect
|
||||
}
|
||||
if !slices.ContainsFunc(current.Accounts, func(linked domain.Account) bool {
|
||||
return linked.ExternalAccountID == account.ExternalAccountID
|
||||
}) {
|
||||
if !slices.Contains(current.AccountIDs, account.ExternalAccountID) {
|
||||
return ImportResult{}, banking.ErrReconnect
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
facts, err := a.bank.Transactions(ctx, account, now.AddDate(0, -historyMonths, 0).Format("2006-01-02"), now.Format("2006-01-02"))
|
||||
if err != nil {
|
||||
if errors.Is(err, banking.ErrReconnect) {
|
||||
return ImportResult{}, banking.ErrReconnect
|
||||
}
|
||||
return ImportResult{}, errors.New("transaction retrieval failed; retry importing history")
|
||||
return ImportResult{}, bankFailure(err, "transaction retrieval failed; retry importing history")
|
||||
}
|
||||
// Use normal import processing without changing sync cursors or saved consent
|
||||
// settings, including when the requested range adds no transactions.
|
||||
@@ -302,6 +295,20 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error
|
||||
}
|
||||
return nil, errors.New("account is not connected")
|
||||
}
|
||||
|
||||
// Only typed, locally generated errors are safe to expose; provider errors may
|
||||
// wrap private response data even when their underlying cause is recognizable.
|
||||
func bankFailure(err error, fallback string) error {
|
||||
var limited *ratelimit.RateLimitError
|
||||
if errors.As(err, &limited) {
|
||||
return fmt.Errorf("Enable Banking: %w", limited)
|
||||
}
|
||||
if errors.Is(err, banking.ErrReconnect) {
|
||||
return banking.ErrReconnect
|
||||
}
|
||||
return errors.New(fallback)
|
||||
}
|
||||
|
||||
func (a *App) Sync(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -343,25 +350,35 @@ func (a *App) Sync(ctx context.Context) (State, error) {
|
||||
}
|
||||
validAccounts := map[string]bool{}
|
||||
accountSession := map[string]string{}
|
||||
failedSessions := map[string]bool{}
|
||||
for i, session := range a.ops.Sessions {
|
||||
for _, account := range session.Accounts {
|
||||
accountSession[account.ID] = session.ID
|
||||
}
|
||||
meta := a.ops.Consents[session.ID]
|
||||
current, e := a.bank.Status(ctx, session.ID)
|
||||
if e == nil {
|
||||
expiry, parseErr := time.Parse(time.RFC3339, current.ValidUntil)
|
||||
if parseErr != nil || !expiry.After(time.Now()) {
|
||||
e = banking.ErrReconnect
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
meta.Error = e.Error()
|
||||
meta.Error = bankFailure(e, "bank connection unavailable; retry synchronization").Error()
|
||||
meta.NeedsReconnect = errors.Is(e, banking.ErrReconnect)
|
||||
a.ops.Consents[session.ID] = meta
|
||||
failures = append(failures, meta.Institution+": "+meta.Error)
|
||||
failedSessions[session.ID] = true
|
||||
continue
|
||||
}
|
||||
meta.Error = ""
|
||||
meta.NeedsReconnect = false
|
||||
a.ops.Consents[session.ID] = meta
|
||||
a.ops.Sessions[i].ValidUntil = current.ValidUntil
|
||||
for _, account := range current.Accounts {
|
||||
validAccounts[account.ExternalAccountID] = true
|
||||
for _, account := range session.Accounts {
|
||||
if account.ExternalAccountID != "" && slices.Contains(current.AccountIDs, account.ExternalAccountID) {
|
||||
validAccounts[account.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -370,8 +387,18 @@ func (a *App) Sync(ctx context.Context) (State, error) {
|
||||
if !account.Active || account.ExternalAccountID == "" {
|
||||
continue
|
||||
}
|
||||
if !validAccounts[account.ExternalAccountID] {
|
||||
sessionID := accountSession[account.ID]
|
||||
if failedSessions[sessionID] {
|
||||
continue
|
||||
}
|
||||
if !validAccounts[account.ID] {
|
||||
failures = append(failures, account.DisplayName+": bank connection unavailable")
|
||||
if sessionID != "" {
|
||||
meta := a.ops.Consents[sessionID]
|
||||
meta.Error = banking.ErrReconnect.Error()
|
||||
meta.NeedsReconnect = true
|
||||
a.ops.Consents[sessionID] = meta
|
||||
}
|
||||
continue
|
||||
}
|
||||
var from string
|
||||
@@ -383,10 +410,11 @@ func (a *App) Sync(ctx context.Context) (State, error) {
|
||||
}
|
||||
facts, e := a.bank.Transactions(ctx, account, from, to)
|
||||
if e != nil {
|
||||
meta := a.ops.Consents[accountSession[account.ID]]
|
||||
meta.Error = "Transaction retrieval failed; retry synchronization"
|
||||
a.ops.Consents[accountSession[account.ID]] = meta
|
||||
failures = append(failures, account.DisplayName+": transaction retrieval failed")
|
||||
meta := a.ops.Consents[sessionID]
|
||||
meta.Error = bankFailure(e, "transaction retrieval failed; retry synchronization").Error()
|
||||
meta.NeedsReconnect = meta.NeedsReconnect || errors.Is(e, banking.ErrReconnect)
|
||||
a.ops.Consents[sessionID] = meta
|
||||
failures = append(failures, account.DisplayName+": "+meta.Error)
|
||||
continue
|
||||
}
|
||||
result, e := a.importFacts(ctx, s, facts)
|
||||
|
||||
@@ -70,7 +70,7 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
||||
}
|
||||
a.mu.Lock()
|
||||
s, err := a.snapshot(ctx)
|
||||
client := a.classifier
|
||||
client := a.classifier.WithModel(r.Model)
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
@@ -78,7 +78,6 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
||||
if r.Revision != s.Revision {
|
||||
return Preview{}, errors.New("revision conflict: reload before analysing")
|
||||
}
|
||||
client.Model = r.Model
|
||||
p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
|
||||
baseMerchants := len(s.Data.Merchants)
|
||||
for _, t := range s.Data.Transactions {
|
||||
@@ -90,6 +89,9 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
||||
}
|
||||
p.Analysed++
|
||||
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
|
||||
if err = ctx.Err(); err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if e != nil {
|
||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||
continue
|
||||
|
||||
+205
-3
@@ -3,12 +3,17 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/ratelimit"
|
||||
)
|
||||
|
||||
type bankScenario struct {
|
||||
@@ -22,11 +27,15 @@ func (b *bankScenario) Authorize(context.Context, string, string, string) (strin
|
||||
func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) {
|
||||
return b.session, nil
|
||||
}
|
||||
func (b *bankScenario) Status(context.Context, string) (banking.Session, error) {
|
||||
func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, error) {
|
||||
if b.fail {
|
||||
return banking.Session{}, errors.New("expired")
|
||||
return banking.SessionStatus{}, errors.New("expired")
|
||||
}
|
||||
return b.session, nil
|
||||
status := banking.SessionStatus{ValidUntil: b.session.ValidUntil}
|
||||
for _, account := range b.session.Accounts {
|
||||
status.AccountIDs = append(status.AccountIDs, account.ExternalAccountID)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
|
||||
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
|
||||
@@ -110,3 +119,196 @@ func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T)
|
||||
t.Fatal("authorization state replay was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the controller's typed error without contacting a provider or waiting.
|
||||
func bankRateError(t *testing.T) error {
|
||||
t.Helper()
|
||||
var controller ratelimit.Controller
|
||||
ctx := context.Background()
|
||||
if err := controller.Acquire(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Release()
|
||||
_, err := controller.Do(ctx, func(context.Context) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Retry-After": []string{"300"}},
|
||||
Body: io.NopCloser(strings.NewReader("private provider response")),
|
||||
}, nil
|
||||
}, false)
|
||||
if err == nil {
|
||||
t.Fatal("rate limit response was accepted")
|
||||
}
|
||||
return fmt.Errorf("private provider response: %w", err)
|
||||
}
|
||||
|
||||
type sessionBank struct {
|
||||
bankScenario
|
||||
statuses map[string]banking.SessionStatus
|
||||
failures map[string]error
|
||||
fetched []string
|
||||
}
|
||||
|
||||
func (b *sessionBank) Status(_ context.Context, id string) (banking.SessionStatus, error) {
|
||||
return b.statuses[id], b.failures[id]
|
||||
}
|
||||
|
||||
func (b *sessionBank) Transactions(ctx context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
|
||||
b.fetched = append(b.fetched, account.ID)
|
||||
return b.bankScenario.Transactions(ctx, account, from, to)
|
||||
}
|
||||
|
||||
func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
ctx := context.Background()
|
||||
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
||||
d.Accounts[0].ExternalAccountID = "main_uid"
|
||||
for _, id := range []string{"mwst", "tax", "independent"} {
|
||||
d.Accounts = append(d.Accounts, domain.Account{ID: id, DisplayName: id, Currency: "EUR", Active: true, ExternalAccountID: id + "_uid"})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiry := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
|
||||
limited := banking.Session{ID: "limited", ValidUntil: expiry}
|
||||
healthy := banking.Session{ID: "healthy", ValidUntil: expiry}
|
||||
for _, account := range s.Data.Accounts {
|
||||
if account.ID == "independent" {
|
||||
healthy.Accounts = append(healthy.Accounts, account)
|
||||
} else {
|
||||
limited.Accounts = append(limited.Accounts, account)
|
||||
}
|
||||
}
|
||||
a.ops.Sessions = []banking.Session{limited, healthy}
|
||||
a.ops.Consents["limited"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
|
||||
a.ops.Consents["healthy"] = Consent{Institution: "Other", Country: "DE", HistoryMonths: 12}
|
||||
b := &sessionBank{
|
||||
statuses: map[string]banking.SessionStatus{
|
||||
"limited": {ValidUntil: expiry, AccountIDs: []string{"main_uid", "mwst_uid", "tax_uid"}},
|
||||
// Another consent must not authorize an account whose own status failed.
|
||||
"healthy": {ValidUntil: expiry, AccountIDs: []string{"independent_uid", "main_uid"}},
|
||||
},
|
||||
failures: map[string]error{},
|
||||
}
|
||||
a.bank = b
|
||||
before, err := a.Sync(ctx)
|
||||
if err != nil || len(before.Data.Transactions) != 4 {
|
||||
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(before.Data.Transactions), err, before.Status.SyncError)
|
||||
}
|
||||
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
a.ops.LastSync = old
|
||||
for _, account := range before.Data.Accounts {
|
||||
a.ops.AccountSync[account.ID] = old
|
||||
}
|
||||
b.fetched = nil
|
||||
b.failures["limited"] = bankRateError(t)
|
||||
failed, err := a.Sync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(failed.Status.SyncError, "429") != 1 || strings.Contains(failed.Status.SyncError, "bank connection unavailable") || strings.Contains(failed.Status.SyncError, "private provider response") || !strings.Contains(failed.Status.SyncError, "retry") {
|
||||
t.Fatal("session rate limit was duplicated, obscured, or exposed private data")
|
||||
}
|
||||
if !reflect.DeepEqual(b.fetched, []string{"independent"}) {
|
||||
t.Fatal("failed consent authorized retrieval or independent consent stopped syncing")
|
||||
}
|
||||
if !reflect.DeepEqual(before.Data, failed.Data) || !reflect.DeepEqual(before.Sessions, failed.Sessions) || a.ops.LastSync != old {
|
||||
t.Fatal("rate limit changed existing facts, bindings, metadata, or last successful sync")
|
||||
}
|
||||
for _, account := range before.Data.Accounts {
|
||||
if account.ID != "independent" && a.ops.AccountSync[account.ID] != old {
|
||||
t.Fatal("failed account advanced its cursor")
|
||||
}
|
||||
}
|
||||
if a.ops.AccountSync["independent"] == old || a.ops.Consents["limited"].NeedsReconnect || a.ops.Consents["limited"].HistoryMonths != 24 {
|
||||
t.Fatal("rate limit lost consent settings, required reconnect, or stopped the healthy cursor")
|
||||
}
|
||||
a = reopenBankingApp(t, a)
|
||||
a.bank = b
|
||||
if a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync != old || a.ops.AccountSync["mwst"] != old {
|
||||
t.Fatal("rate failure state did not survive restart safely")
|
||||
}
|
||||
delete(b.failures, "limited")
|
||||
b.fetched = nil
|
||||
recovered, err := a.Sync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovered.Status.SyncError != "" || a.ops.Consents["limited"].Error != "" || a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync == old {
|
||||
t.Fatal("successful retry did not clear the transient failure")
|
||||
}
|
||||
if !reflect.DeepEqual(before.Data, recovered.Data) || len(b.fetched) != 4 {
|
||||
t.Fatal("recovery duplicated facts or skipped an account")
|
||||
}
|
||||
for _, account := range recovered.Data.Accounts {
|
||||
if a.ops.AccountSync[account.ID] == old {
|
||||
t.Fatal("recovered account cursor did not advance")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
|
||||
a, s, b := backfillApp(t)
|
||||
b.session.Accounts = []domain.Account{s.Data.Accounts[1]}
|
||||
before := domain.Clone(s.Data)
|
||||
last := a.ops.LastSync
|
||||
after, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(after.Status.SyncError, s.Data.Accounts[0].DisplayName+": bank connection unavailable") || !a.ops.Consents["current"].NeedsReconnect {
|
||||
t.Fatal("missing account membership was treated as authorized")
|
||||
}
|
||||
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
|
||||
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
|
||||
}
|
||||
if !reflect.DeepEqual(before.Accounts, after.Data.Accounts) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
|
||||
t.Fatal("missing membership changed bindings or imported unauthorized facts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
|
||||
for _, scenario := range []string{"rate limit", "reconnect", "private response"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
a, s, b := backfillApp(t)
|
||||
s = seed(t, a, s)
|
||||
old := a.ops.LastSync
|
||||
cursors := map[string]string{}
|
||||
for id, cursor := range a.ops.AccountSync {
|
||||
cursors[id] = cursor
|
||||
}
|
||||
switch scenario {
|
||||
case "rate limit":
|
||||
b.fetchErr = bankRateError(t)
|
||||
case "reconnect":
|
||||
b.fetchErr = fmt.Errorf("private provider response: %w", banking.ErrReconnect)
|
||||
case "private response":
|
||||
b.fetchErr = errors.New("private provider response")
|
||||
}
|
||||
failed, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := a.ops.Consents["current"]
|
||||
if failed.Status.SyncError == "" || meta.Error == "" || strings.Contains(failed.Status.SyncError+meta.Error, "private provider response") {
|
||||
t.Fatal("transaction failure was lost or exposed provider data")
|
||||
}
|
||||
if scenario == "rate limit" && (!strings.Contains(failed.Status.SyncError, "429") || !strings.Contains(meta.Error, "429") || !strings.Contains(meta.Error, "retry")) {
|
||||
t.Fatal("transaction rate error was obscured")
|
||||
}
|
||||
if meta.NeedsReconnect != (scenario == "reconnect") {
|
||||
t.Fatal("transaction failure classified consent incorrectly")
|
||||
}
|
||||
if !reflect.DeepEqual(s.Data, failed.Data) || !reflect.DeepEqual(cursors, a.ops.AccountSync) || a.ops.LastSync != old {
|
||||
t.Fatal("failed retrieval imported partial data or advanced synchronization")
|
||||
}
|
||||
b.fetchErr = nil
|
||||
recovered, err := a.Sync(context.Background())
|
||||
if err != nil || recovered.Status.SyncError != "" || a.ops.Consents["current"].Error != "" || a.ops.Consents["current"].NeedsReconnect {
|
||||
t.Fatal("successful retrieval did not clear the failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user