257 lines
9.2 KiB
Go
257 lines
9.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
type backfillBank struct {
|
|
historyBank
|
|
statusIDs []string
|
|
accounts []domain.Account
|
|
toDates []string
|
|
statusErr error
|
|
fetchErr 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.SessionStatus{}, b.statusErr
|
|
}
|
|
return b.historyBank.Status(ctx, id)
|
|
}
|
|
|
|
func (b *backfillBank) Transactions(ctx context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
|
|
b.accounts = append(b.accounts, account)
|
|
b.toDates = append(b.toDates, to)
|
|
rows, err := b.historyBank.Transactions(ctx, account, from, to)
|
|
if b.fetchErr != nil {
|
|
// A provider can fail after accumulating a page: none of it is importable.
|
|
return rows, b.fetchErr
|
|
}
|
|
return rows, err
|
|
}
|
|
|
|
func backfillApp(t *testing.T) (*App, State, *backfillBank) {
|
|
t.Helper()
|
|
a, s := testApp(t)
|
|
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
|
|
d.Accounts[0].ExternalAccountID = "selected_uid"
|
|
d.Accounts = append(d.Accounts, domain.Account{ID: "other", DisplayName: "Other", Currency: "EUR", Active: true, ExternalAccountID: "other_uid"})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := banking.Session{ID: "current", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts}
|
|
b := &backfillBank{historyBank: historyBank{bankScenario: bankScenario{session: session}}}
|
|
a.bank = b
|
|
a.ops.Sessions = []banking.Session{session}
|
|
a.ops.Consents[session.ID] = Consent{Institution: "Bank", Country: "DE", HistoryMonths: 3}
|
|
a.ops.LastSync = time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
|
for _, account := range s.Data.Accounts {
|
|
a.ops.AccountSync[account.ID] = a.ops.LastSync
|
|
}
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a, s, b
|
|
}
|
|
|
|
func TestBackfillImportsOlderFactsOnceWithoutChangingSyncState(t *testing.T) {
|
|
a, _, b := backfillApp(t)
|
|
ctx := context.Background()
|
|
s, err := a.Sync(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(s.Data.Transactions) != 2 {
|
|
t.Fatal("recent sync did not seed both accounts")
|
|
}
|
|
s, err = a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
|
d.Accounts[0].Active = false // Inactive disables scheduling, not explicit backfill.
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := a.ops.Sessions[0]
|
|
old.ID = "superseded"
|
|
a.ops.Sessions = append([]banking.Session{old}, a.ops.Sessions...)
|
|
a.ops.Consents["superseded"] = Consent{HistoryMonths: 120}
|
|
meta := a.ops.Consents["current"]
|
|
meta.Error = "Previous temporary failure"
|
|
a.ops.Consents["current"] = meta
|
|
a.ops.SyncError = "Previous temporary failure"
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeOps, err := json.Marshal(a.ops)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.statusIDs, b.accounts, b.fromDates, b.toDates = nil, nil, nil, nil
|
|
// Provider-local IDs need not match our canonical account ID.
|
|
providerAccount := s.Data.Accounts[0]
|
|
providerAccount.ID = "provider_generated_id"
|
|
b.session.Accounts = []domain.Account{providerAccount}
|
|
fromBefore := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
|
|
toBefore := time.Now().UTC().Format("2006-01-02")
|
|
result, err := a.Backfill(ctx, s.Revision, s.Data.Accounts[0].ID, 24)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fromAfter := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
|
|
toAfter := time.Now().UTC().Format("2006-01-02")
|
|
if result.Imported != 2 || len(result.State.Data.Transactions) != 4 {
|
|
t.Fatalf("older history import count is wrong: %+v", result)
|
|
}
|
|
if !reflect.DeepEqual(b.statusIDs, []string{"current"}) || !reflect.DeepEqual(b.accounts, []domain.Account{s.Data.Accounts[0]}) {
|
|
t.Fatal("backfill did not use only the newest matching consent and canonical account")
|
|
}
|
|
if len(b.fromDates) != 1 || (b.fromDates[0] != fromBefore && b.fromDates[0] != fromAfter) || (b.toDates[0] != toBefore && b.toDates[0] != toAfter) {
|
|
t.Fatalf("backfill did not request the selected calendar-month range: %v to %v", b.fromDates, b.toDates)
|
|
}
|
|
for _, existing := range s.Data.Transactions {
|
|
found := false
|
|
for _, tx := range result.State.Data.Transactions {
|
|
if tx.Facts.ID == existing.Facts.ID {
|
|
found = reflect.DeepEqual(tx, existing)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("backfill changed an existing transaction")
|
|
}
|
|
}
|
|
counts := map[string]int{}
|
|
for _, tx := range result.State.Data.Transactions {
|
|
counts[tx.Facts.AccountID]++
|
|
}
|
|
if counts[s.Data.Accounts[0].ID] != 3 || counts["other"] != 1 || !reflect.DeepEqual(result.State.Data.Accounts, s.Data.Accounts) {
|
|
t.Fatal("backfill changed unrelated accounts or imported their history")
|
|
}
|
|
again, err := a.Backfill(ctx, result.State.Revision, s.Data.Accounts[0].ID, 24)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again.Imported != 0 || !reflect.DeepEqual(again.State.Data, result.State.Data) {
|
|
t.Fatal("repeated historical range was not idempotent")
|
|
}
|
|
afterOps, err := json.Marshal(a.ops)
|
|
if err != nil || string(afterOps) != string(beforeOps) {
|
|
t.Fatal("backfill changed operational state")
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
afterOps, err = json.Marshal(a.ops)
|
|
if err != nil || string(afterOps) != string(beforeOps) {
|
|
t.Fatal("backfill changed persisted cursors, consent history or bindings")
|
|
}
|
|
persisted, err := a.Snapshot(ctx)
|
|
if err != nil || !reflect.DeepEqual(persisted.Data, result.State.Data) {
|
|
t.Fatal("historical facts did not survive restart")
|
|
}
|
|
}
|
|
|
|
func TestBackfillRejectsUnsafePrerequisitesBeforeProviderContact(t *testing.T) {
|
|
for _, scenario := range []string{"stale revision", "zero months", "too many months", "missing account", "local account", "unbound account", "mismatched provider ID", "mismatched canonical ID", "expired consent", "reconnect required"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
a, s, b := backfillApp(t)
|
|
rev, id, months := s.Revision, s.Data.Accounts[0].ID, 12
|
|
switch scenario {
|
|
case "stale revision":
|
|
rev = "stale"
|
|
case "zero months":
|
|
months = 0
|
|
case "too many months":
|
|
months = 121
|
|
case "missing account":
|
|
id = "missing"
|
|
case "local account":
|
|
var err error
|
|
s, err = a.Mutate(context.Background(), rev, func(d *domain.Dataset) error { d.Accounts[0].ExternalAccountID = ""; return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rev = s.Revision
|
|
case "unbound account":
|
|
a.ops.Sessions = nil
|
|
case "mismatched provider ID":
|
|
a.ops.Sessions[0].Accounts[0].ExternalAccountID = "wrong_uid"
|
|
case "mismatched canonical ID":
|
|
a.ops.Sessions[0].Accounts[0].ID = "wrong_id"
|
|
case "expired consent":
|
|
a.ops.Sessions[0].ValidUntil = time.Now().Add(-time.Hour).Format(time.RFC3339)
|
|
case "reconnect required":
|
|
a.ops.Consents["current"] = Consent{NeedsReconnect: true}
|
|
}
|
|
if _, err := a.Backfill(context.Background(), rev, id, months); err == nil {
|
|
t.Fatal("unsafe backfill was accepted")
|
|
}
|
|
if len(b.statusIDs) != 0 || len(b.accounts) != 0 {
|
|
t.Fatal("unsafe prerequisites contacted the provider")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackfillProviderFailuresDoNotImportPartialData(t *testing.T) {
|
|
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)
|
|
switch scenario {
|
|
case "not configured":
|
|
a.bank = nil
|
|
case "unavailable":
|
|
b.statusErr = errors.New("private provider response")
|
|
case "revoked":
|
|
b.statusErr = banking.ErrReconnect
|
|
case "provider expired":
|
|
b.session.ValidUntil = time.Now().Add(-time.Hour).Format(time.RFC3339)
|
|
case "account absent":
|
|
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 {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := a.Backfill(context.Background(), s.Revision, s.Data.Accounts[0].ID, 24)
|
|
if err == nil || result.Imported != 0 {
|
|
t.Fatal("failed provider request reported a successful import")
|
|
}
|
|
if strings.Contains(err.Error(), "private provider response") {
|
|
t.Fatal("provider error exposed private response data")
|
|
}
|
|
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())
|
|
if err != nil || current.Revision != s.Revision || !reflect.DeepEqual(current.Data, s.Data) {
|
|
t.Fatal("provider failure changed canonical data")
|
|
}
|
|
afterOps, err := json.Marshal(a.ops)
|
|
if err != nil || string(afterOps) != string(beforeOps) {
|
|
t.Fatal("provider failure changed operational state")
|
|
}
|
|
})
|
|
}
|
|
}
|