Import the longest bank-permitted history and surface real provider errors

Manual history imports failed opaquely once a bank capped lookback on an
established consent (N26 rejects date_from beyond ~90 days with
WRONG_TRANSACTIONS_PERIOD). Backfill now requests the documented longest
fetching strategy, reports the coverage the bank actually provided, and
non-2xx responses surface allowlisted documented error codes instead of a
generic fallback. Dead-session codes map to reconnection. Failed syncs
retry hourly so a stale sync banner no longer persists for a day.
This commit is contained in:
Lars Nolden
2026-09-10 22:58:03 +02:00
parent 4324660888
commit a8722d58c3
7 changed files with 213 additions and 36 deletions
+55 -7
View File
@@ -16,6 +16,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
@@ -193,7 +194,7 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "1234.5678" || balances[0].Type != "CLBD" {
t.Fatalf("balance precision lost: %+v %v", balances, err)
}
transactions, err := p.Transactions(context.Background(), session.Accounts[0], "2026-09-01", "2026-09-30")
transactions, err := p.Transactions(context.Background(), session.Accounts[0], "2026-09-01", "2026-09-30", false)
if err != nil {
t.Fatal(err)
}
@@ -227,11 +228,58 @@ func TestEnableBankingRejectsPaginationCyclesAndPartialResults(t *testing.T) {
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err == nil || rows != nil {
t.Fatal("pagination cycle returned partial import")
}
}
func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) {
var strategies []string
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
strategies = append(strategies, r.URL.Query().Get("strategy"))
fmt.Fprint(w, `{"transactions":[]}`)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
if _, err := p.Transactions(context.Background(), account, "2020-01-01", "", true); err != nil {
t.Fatal(err)
}
if _, err := p.Transactions(context.Background(), account, "2026-09-01", "2026-09-30", false); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(strategies, []string{"longest", ""}) {
t.Fatalf("wrong fetching strategies requested: %q", strategies)
}
}
func TestEnableBankingSurfacesOnlyDocumentedErrorCodes(t *testing.T) {
body := ""
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, body)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
fetch := func() error {
_, err := p.Transactions(context.Background(), account, "2020-01-01", "", true)
return err
}
body = `{"code":422,"error":"WRONG_TRANSACTIONS_PERIOD","message":"private-bank-text","detail":"private-account-detail"}`
err := fetch()
if err == nil || !strings.Contains(err.Error(), "WRONG_TRANSACTIONS_PERIOD") || !strings.Contains(err.Error(), "422") {
t.Fatalf("documented period error was hidden: %v", err)
}
if strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
t.Fatalf("unsafe or misclassified period error: %v", err)
}
body = `{"code":422,"error":"UNDOCUMENTED_PRIVATE_CODE","detail":"secret"}`
if err = fetch(); err == nil || strings.Contains(err.Error(), "UNDOCUMENTED") || strings.Contains(err.Error(), "secret") || !strings.Contains(err.Error(), "422") {
t.Fatalf("undocumented provider code leaked: %v", err)
}
body = `{"code":404,"error":"SESSION_DOES_NOT_EXIST"}`
if err = fetch(); !errors.Is(err, ErrReconnect) {
t.Fatalf("dead session did not request reconnection: %v", err)
}
}
func TestEnableBankingSessionRevocationAndInvalidBookedDates(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/sessions/") {
@@ -245,7 +293,7 @@ func TestEnableBankingSessionRevocationAndInvalidBookedDates(t *testing.T) {
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err == nil || rows != nil {
t.Fatal("invented booking date for missing bank fact")
}
@@ -352,7 +400,7 @@ func TestEnableBankingGETRecoversAfterRateLimit(t *testing.T) {
} else {
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err != nil || len(rows) != 1 || rows[0].ExternalID != "entry" || rows[0].Amount.String() != "1.00" {
t.Fatalf("transaction retrieval did not recover: %+v %v", rows, err)
}
@@ -381,7 +429,7 @@ func TestEnableBankingCooldownCoversAllEndpoints(t *testing.T) {
for name, request := range map[string]func() error{
"status": func() error { _, err := p.Status(context.Background(), "other-session"); return err },
"balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err },
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", ""); return err },
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", "", false); return err },
"exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err },
"authorize": func() error { _, err := p.Authorize(context.Background(), "N26", "DE", "state"); return err },
} {
@@ -509,7 +557,7 @@ func TestEnableBankingPSUSurvivesRetryAndPaginationWithoutLeakingToBackground(t
ctx := WithPSU(context.Background(), psu)
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(ctx, account, "2026-01-01", "")
rows, err := p.Transactions(ctx, account, "2026-01-01", "", false)
if err != nil || len(rows) != 1 || rows[0].ExternalID != "entry" {
t.Fatalf("manual history failed: %+v %v", rows, err)
}
@@ -565,7 +613,7 @@ func TestEnableBankingBackgroundQuotaIsScopedAndExpires(t *testing.T) {
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
if _, err := p.Transactions(context.Background(), account, "", ""); err != nil {
if _, err := p.Transactions(context.Background(), account, "", "", false); err != nil {
t.Fatalf("balance quota blocked transaction endpoint: %v", err)
}
// Simulate the stored deadline passing without a six-hour wall-clock wait.