135 lines
5.1 KiB
Go
135 lines
5.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
type historyBank struct {
|
|
bankScenario
|
|
fetched chan struct{}
|
|
}
|
|
|
|
func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
|
|
var rows []domain.Facts
|
|
for _, days := range []int{60, 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}
|
|
a.bank = &historyBank{bankScenario: bankScenario{session: session}}
|
|
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
|
|
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 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)
|
|
}
|
|
}
|
|
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"}
|
|
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")
|
|
}
|
|
}
|