package app import ( "context" "testing" "time" "finance-duck/internal/banking" "finance-duck/internal/domain" ) type historyBank struct { bankScenario fetched chan struct{} authState string authorizations int fromDates []string } func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) { b.authState = state b.authorizations++ return "https://bank.example/authorize", nil } func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string) ([]domain.Facts, error) { b.fromDates = append(b.fromDates, from) var rows []domain.Facts for _, days := range []int{400, 300, 1} { date := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02") if date >= from && date <= to { rows = append(rows, domain.Facts{Source: "enablebanking", AccountID: account.ID, BookingDate: date, Amount: "-10.00", Currency: "EUR", RawDescription: "Card payment", ExternalID: "entry_" + date}) } } if b.fetched != nil { select { case b.fetched <- struct{}{}: default: } } return rows, nil } func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) { a, s := testApp(t) old := s.Data.Accounts[0] old.ExternalAccountID = "old_uid" fresh := domain.Account{ID: "ing", DisplayName: "ING", Institution: "ING", Currency: "EUR", ExternalAccountID: "new_uid", Active: true} s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { d.Accounts = []domain.Account{old, fresh}; return nil }) if err != nil { t.Fatal(err) } session := banking.Session{ID: "consent", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts} b := &historyBank{bankScenario: bankScenario{session: session}} a.bank = b a.ops.Sessions = []banking.Session{session} a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) a.ops.AccountSync[old.ID] = a.ops.LastSync if err := a.saveOps(); err != nil { t.Fatal(err) } a = reopenBankingApp(t, a) a.bank = b after, err := a.Sync(context.Background()) if err != nil { t.Fatal(err) } counts := map[string]int{} for _, tx := range after.Data.Transactions { counts[tx.Facts.AccountID]++ } if counts[old.ID] != 1 || counts[fresh.ID] != 2 { t.Fatalf("new account history skipped: %v", counts) } } func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.T) { a, s := testApp(t) ctx := context.Background() account := s.Data.Accounts[0] account.ExternalAccountID = "history_uid" b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}} a.bank = b for _, months := range []int{-1, 0, 121} { if _, err := a.Authorize(ctx, "N26", "DE", months); err == nil { t.Fatalf("accepted invalid history choice %d", months) } } if b.authorizations != 0 { t.Fatal("invalid history choice reached the bank") } if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil { t.Fatal(err) } if err := a.Callback(ctx, "one_time_code", b.authState); err != nil { t.Fatal(err) } a = reopenBankingApp(t, a) a.bank = b s, err := a.Snapshot(ctx) if err != nil { t.Fatal(err) } if len(s.Connections) != 1 || s.Connections[0].HistoryMonths != 24 { t.Fatalf("saved history choice unavailable after restart: %+v", s.Connections) } before := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02") first, err := a.Sync(ctx) if err != nil { t.Fatal(err) } after := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02") if len(first.Data.Transactions) != 3 { t.Fatalf("selected history did not import older transactions: %+v", first.Data.Transactions) } if len(b.fromDates) != 1 || (b.fromDates[0] != before && b.fromDates[0] != after) { t.Fatalf("initial import did not use 24 calendar months: %v", b.fromDates) } cursor, err := time.Parse(time.RFC3339, first.Status.LastSync) if err != nil { t.Fatal(err) } a = reopenBankingApp(t, a) a.bank = b again, err := a.Sync(ctx) if err != nil { t.Fatal(err) } wantFrom := cursor.AddDate(0, 0, -14).Format("2006-01-02") if len(b.fromDates) != 2 || b.fromDates[1] != wantFrom { t.Fatalf("incremental import ignored saved cursor: %v, want %s", b.fromDates, wantFrom) } if len(again.Data.Transactions) != 3 || again.Connections[0].HistoryMonths != 24 { t.Fatal("incremental import duplicated history or lost the selected window") } } func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) { a, s := testApp(t) account := s.Data.Accounts[0] account.ExternalAccountID = "uid" _, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, account) }) if err != nil { t.Fatal(err) } a.ops.Sessions = []banking.Session{{ID: "expired", ValidUntil: time.Now().Add(-time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}} a.ops.Consents["expired"] = Consent{Institution: "ING", Country: "DE"} after, err := a.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if len(after.Connections) != 1 || after.Connections[0].Status != "reconnect_required" || after.Connections[0].Institution != "ING" { t.Fatalf("missing bank reconnect status: %+v", after.Connections) } if after.Connections[0].HistoryMonths != 12 { t.Fatal("legacy consent did not retain the default reconnect history") } } func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) { a, s := testApp(t) account := s.Data.Accounts[0] account.ExternalAccountID = "renewed_uid" b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "renewed_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}, fetched: make(chan struct{}, 1)} a.bank = b a.ops.LastSync = time.Now().UTC().Format(time.RFC3339) a.authStates["state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE", HistoryMonths: 12} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done); a.RunScheduler(ctx) }() defer func() { cancel(); <-done }() if err := a.Callback(context.Background(), "one_time_code", "state"); err != nil { t.Fatal(err) } select { case <-b.fetched: case <-time.After(10 * time.Second): t.Fatal("renewal did not wake automatic synchronization") } after, err := a.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if len(after.Data.Transactions) != 2 || len(after.Sessions) != 1 || after.Connections[0].Status != "connected" { t.Fatalf("renewed consent not usable: %+v", after) } } type recoveryBank struct{ historyBank } func (b *recoveryBank) Status(ctx context.Context, id string) (banking.Session, error) { if id != b.session.ID { return banking.Session{}, banking.ErrReconnect } return b.session, nil } func TestInterruptedRenewalDiscardsSupersededConsentDuringRecovery(t *testing.T) { a, s := testApp(t) old := s.Data.Accounts[0] old.ExternalAccountID = "old_uid" renewed := old renewed.ExternalAccountID = "new_uid" session := banking.Session{ID: "new_session", ValidUntil: time.Now().Add(time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}} a.bank = &recoveryBank{historyBank{bankScenario: bankScenario{session: session}}} a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{old}}, session} after, err := a.Sync(context.Background()) if err != nil { t.Fatal(err) } if after.Status.SyncError != "" || len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" { t.Fatalf("superseded consent survived recovery: %+v", after) } if len(after.Data.Transactions) != 2 || after.Connections[0].Status != "connected" { t.Fatal("recovered replacement did not resume imports") } }