package app import ( "context" "errors" "reflect" "testing" "time" "finance-duck/internal/banking" "finance-duck/internal/domain" ) type bankScenario struct { session banking.Session fail bool } func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) { return "https://bank.example/authorize", nil } func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) { return b.session, nil } func (b *bankScenario) Status(context.Context, string) (banking.Session, error) { if b.fail { return banking.Session{}, errors.New("expired") } return b.session, nil } func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) { return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil } func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string) ([]domain.Facts, error) { if b.fail { return nil, errors.New("offline") } return []domain.Facts{{Source: "enablebanking", AccountID: a.ID, BookingDate: time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02"), Amount: "-42.80", Currency: "EUR", RawDescription: "REWE", ExternalID: "entry_stable"}}, nil } func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T) { a, s := testApp(t) account := s.Data.Accounts[0] account.ExternalAccountID = "provider_new" account.IBAN = "DE89370400440532013000" provider := &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}} a.bank = provider a.ops.Sessions = []banking.Session{provider.session} if err := a.saveOps(); err != nil { t.Fatal(err) } first, err := a.Sync(context.Background()) if err != nil { t.Fatal(err) } if len(first.Data.Accounts) != 1 || first.Data.Accounts[0].ExternalAccountID != "provider_new" || len(first.Data.Transactions) != 1 { t.Fatalf("saved session did not recover/import: %+v", first.Data) } again, err := a.Sync(context.Background()) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(first.Data, again.Data) { t.Fatal("repeated bank synchronization changed canonical financial data") } provider.fail = true failed, err := a.Sync(context.Background()) if err != nil { t.Fatal(err) } if failed.Status.SyncError == "" || !reflect.DeepEqual(again.Data, failed.Data) { t.Fatal("provider failure was not isolated from canonical data") } } func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) { a, s := testApp(t) account := s.Data.Accounts[0] account.ExternalAccountID = "old_uid" account.IBAN = "DE89370400440532013000" s, 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: "old_session", Accounts: []domain.Account{account}}} renewed := account renewed.ID = "provider_local_id" renewed.ExternalAccountID = "new_uid" renewed.DisplayName = "Bank-generated name" a.bank = &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}} a.authStates["one_time_state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE"} if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err != nil { t.Fatal(err) } after, err := a.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if len(after.Data.Accounts) != 1 || after.Data.Accounts[0].ID != account.ID || after.Data.Accounts[0].DisplayName != account.DisplayName || after.Data.Accounts[0].ExternalAccountID != "new_uid" { t.Fatal("reconnect duplicated account or lost local display name") } if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" { t.Fatal("expired session remains active after reconnect") } if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil { t.Fatal("authorization state replay was accepted") } }