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") } }) } }