479 lines
20 KiB
Go
479 lines
20 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
"finance-duck/internal/ratelimit"
|
|
)
|
|
|
|
type bankScenario struct {
|
|
session banking.Session
|
|
fail bool
|
|
balances []banking.Balance
|
|
}
|
|
|
|
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
|
|
return "https://bank.example/authorize", nil
|
|
}
|
|
func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) {
|
|
return []banking.Institution{{Name: "N26", Country: "DE"}}, nil
|
|
}
|
|
func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) {
|
|
return b.session, nil
|
|
}
|
|
func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, error) {
|
|
if b.fail {
|
|
return banking.SessionStatus{}, errors.New("expired")
|
|
}
|
|
status := banking.SessionStatus{ValidUntil: b.session.ValidUntil}
|
|
for _, account := range b.session.Accounts {
|
|
status.AccountIDs = append(status.AccountIDs, account.ExternalAccountID)
|
|
}
|
|
return status, nil
|
|
}
|
|
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
|
|
if b.balances != nil {
|
|
return b.balances, nil
|
|
}
|
|
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
|
|
}
|
|
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]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")
|
|
}
|
|
}
|
|
|
|
// The first successful sync fixes the start balance from the bank's booked
|
|
// figure only: an available balance includes pending amounts with no booked
|
|
// fact to subtract, and a later balance change must never move an anchor that
|
|
// has been set — the anchor is the day a figure was true, not a mirror.
|
|
func TestSyncAnchorsBalanceOnceFromBookedFigureOnly(t *testing.T) {
|
|
a, s := testApp(t)
|
|
account := s.Data.Accounts[0]
|
|
account.ExternalAccountID = "provider_uid"
|
|
provider := &bankScenario{
|
|
session: banking.Session{ID: "session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}},
|
|
balances: []banking.Balance{{Amount: "999.99", Currency: "EUR", Type: "ITAV"}},
|
|
}
|
|
a.bank = provider
|
|
a.ops.Sessions = []banking.Session{provider.session}
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unbooked, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := unbooked.Data.Accounts[0]; got.AnchorBalance != "" || got.AnchorDate != "" {
|
|
t.Fatalf("available-only balance was anchored: %+v", got)
|
|
}
|
|
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
|
|
older := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
|
|
provider.balances = append(provider.balances,
|
|
banking.Balance{Amount: "240.00", Currency: "EUR", Type: "CLBD", ReferenceDate: older},
|
|
banking.Balance{Amount: "250.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday},
|
|
)
|
|
anchored, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := anchored.Data.Accounts[0]; got.AnchorBalance != "250.00" || got.AnchorDate != yesterday {
|
|
t.Fatalf("booked balance was not anchored at its reference day: %+v", got)
|
|
}
|
|
provider.balances = []banking.Balance{{Amount: "300.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday}}
|
|
retained, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(anchored.Data, retained.Data) {
|
|
t.Fatal("a later balance moved an existing anchor")
|
|
}
|
|
}
|
|
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}}}
|
|
a.ops.Consents["old_session"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
|
|
cursor := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
|
a.ops.AccountSync[account.ID] = cursor
|
|
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", HistoryMonths: 24}
|
|
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 a.ops.AccountSync[account.ID] != cursor || after.Connections[0].HistoryMonths != 24 {
|
|
t.Fatal("reconnect reset the account cursor or lost the history choice")
|
|
}
|
|
if _, err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil {
|
|
t.Fatal("authorization state replay was accepted")
|
|
}
|
|
}
|
|
|
|
// Exercise the controller's typed error without contacting a provider or waiting.
|
|
func bankRateError(t *testing.T) error {
|
|
t.Helper()
|
|
var controller ratelimit.Controller
|
|
ctx := context.Background()
|
|
if err := controller.Acquire(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Release()
|
|
_, err := controller.Do(ctx, func(context.Context) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Header: http.Header{"Retry-After": []string{"300"}},
|
|
Body: io.NopCloser(strings.NewReader("private provider response")),
|
|
}, nil
|
|
}, false)
|
|
if err == nil {
|
|
t.Fatal("rate limit response was accepted")
|
|
}
|
|
return fmt.Errorf("private provider response: %w", err)
|
|
}
|
|
|
|
type sessionBank struct {
|
|
bankScenario
|
|
statuses map[string]banking.SessionStatus
|
|
failures map[string]error
|
|
fetched []string
|
|
}
|
|
|
|
func (b *sessionBank) Status(_ context.Context, id string) (banking.SessionStatus, error) {
|
|
return b.statuses[id], b.failures[id]
|
|
}
|
|
|
|
func (b *sessionBank) Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error) {
|
|
b.fetched = append(b.fetched, account.ID)
|
|
return b.bankScenario.Transactions(ctx, account, from, to, longest)
|
|
}
|
|
|
|
func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
|
|
a, s := testApp(t)
|
|
ctx := context.Background()
|
|
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
|
d.Accounts[0].ExternalAccountID = "main_uid"
|
|
for _, id := range []string{"mwst", "tax", "independent"} {
|
|
d.Accounts = append(d.Accounts, domain.Account{ID: id, DisplayName: id, Currency: "EUR", Active: true, ExternalAccountID: id + "_uid"})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expiry := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
|
|
limited := banking.Session{ID: "limited", ValidUntil: expiry}
|
|
healthy := banking.Session{ID: "healthy", ValidUntil: expiry}
|
|
for _, account := range s.Data.Accounts {
|
|
if account.ID == "independent" {
|
|
healthy.Accounts = append(healthy.Accounts, account)
|
|
} else {
|
|
limited.Accounts = append(limited.Accounts, account)
|
|
}
|
|
}
|
|
a.ops.Sessions = []banking.Session{limited, healthy}
|
|
a.ops.Consents["limited"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
|
|
a.ops.Consents["healthy"] = Consent{Institution: "Other", Country: "DE", HistoryMonths: 12}
|
|
b := &sessionBank{
|
|
statuses: map[string]banking.SessionStatus{
|
|
"limited": {ValidUntil: expiry, AccountIDs: []string{"main_uid", "mwst_uid", "tax_uid"}},
|
|
// Another consent must not authorize an account whose own status failed.
|
|
"healthy": {ValidUntil: expiry, AccountIDs: []string{"independent_uid", "main_uid"}},
|
|
},
|
|
failures: map[string]error{},
|
|
}
|
|
a.bank = b
|
|
first, err := a.Sync(ctx)
|
|
if err != nil || len(first.Data.Transactions) != 4 {
|
|
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(first.Data.Transactions), err, first.Status.SyncError)
|
|
}
|
|
// The first successful sync also anchors each account's balance; a second
|
|
// sync reaches the steady state where the session bindings have absorbed
|
|
// the anchored accounts and nothing changes any more.
|
|
before, err := a.Sync(ctx)
|
|
if err != nil || !reflect.DeepEqual(first.Data, before.Data) {
|
|
t.Fatalf("steady-state sync changed canonical data: %v", err)
|
|
}
|
|
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
|
|
a.ops.LastSync = old
|
|
for _, account := range before.Data.Accounts {
|
|
a.ops.AccountSync[account.ID] = old
|
|
}
|
|
b.fetched = nil
|
|
b.failures["limited"] = bankRateError(t)
|
|
failed, err := a.Sync(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Count(failed.Status.SyncError, "429") != 1 || strings.Contains(failed.Status.SyncError, "bank connection unavailable") || strings.Contains(failed.Status.SyncError, "private provider response") || !strings.Contains(failed.Status.SyncError, "retry") {
|
|
t.Fatal("session rate limit was duplicated, obscured, or exposed private data")
|
|
}
|
|
if !reflect.DeepEqual(b.fetched, []string{"independent"}) {
|
|
t.Fatal("failed consent authorized retrieval or independent consent stopped syncing")
|
|
}
|
|
if !reflect.DeepEqual(before.Data, failed.Data) || !reflect.DeepEqual(before.Sessions, failed.Sessions) || a.ops.LastSync != old {
|
|
t.Fatal("rate limit changed existing facts, bindings, metadata, or last successful sync")
|
|
}
|
|
for _, account := range before.Data.Accounts {
|
|
if account.ID != "independent" && a.ops.AccountSync[account.ID] != old {
|
|
t.Fatal("failed account advanced its cursor")
|
|
}
|
|
}
|
|
if a.ops.AccountSync["independent"] == old || a.ops.Consents["limited"].NeedsReconnect || a.ops.Consents["limited"].HistoryMonths != 24 {
|
|
t.Fatal("rate limit lost consent settings, required reconnect, or stopped the healthy cursor")
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
a.bank = b
|
|
if a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync != old || a.ops.AccountSync["mwst"] != old {
|
|
t.Fatal("rate failure state did not survive restart safely")
|
|
}
|
|
delete(b.failures, "limited")
|
|
b.fetched = nil
|
|
recovered, err := a.Sync(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recovered.Status.SyncError != "" || a.ops.Consents["limited"].Error != "" || a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync == old {
|
|
t.Fatal("successful retry did not clear the transient failure")
|
|
}
|
|
if !reflect.DeepEqual(before.Data, recovered.Data) || len(b.fetched) != 4 {
|
|
t.Fatal("recovery duplicated facts or skipped an account")
|
|
}
|
|
for _, account := range recovered.Data.Accounts {
|
|
if a.ops.AccountSync[account.ID] == old {
|
|
t.Fatal("recovered account cursor did not advance")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
|
|
a, s, b := backfillApp(t)
|
|
b.session.Accounts = []domain.Account{s.Data.Accounts[1]}
|
|
before := domain.Clone(s.Data)
|
|
last := a.ops.LastSync
|
|
after, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(after.Status.SyncError, s.Data.Accounts[0].DisplayName+": bank connection unavailable") || !a.ops.Consents["current"].NeedsReconnect {
|
|
t.Fatal("missing account membership was treated as authorized")
|
|
}
|
|
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
|
|
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
|
|
}
|
|
if !reflect.DeepEqual(before.Accounts[0], after.Data.Accounts[0]) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
|
|
t.Fatal("missing membership changed bindings or imported unauthorized facts")
|
|
}
|
|
// The authorized member's first successful sync anchors its balance from
|
|
// the bank's booked figure; the rejected member must not gain one.
|
|
anchored := after.Data.Accounts[1]
|
|
if anchored.AnchorBalance != "100.00" || anchored.AnchorDate == "" {
|
|
t.Fatalf("authorized member was not anchored: %+v", anchored)
|
|
}
|
|
anchored.AnchorBalance, anchored.AnchorDate = "", ""
|
|
if !reflect.DeepEqual(before.Accounts[1], anchored) {
|
|
t.Fatal("anchoring changed more than the anchor on the authorized member")
|
|
}
|
|
}
|
|
|
|
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
|
|
for _, scenario := range []string{"rate limit", "background rate limit", "reconnect", "private response", "unusable response"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
a, s, b := backfillApp(t)
|
|
s = seed(t, a, s)
|
|
old := a.ops.LastSync
|
|
cursors := map[string]string{}
|
|
for id, cursor := range a.ops.AccountSync {
|
|
cursors[id] = cursor
|
|
}
|
|
switch scenario {
|
|
case "rate limit":
|
|
b.fetchErr = bankRateError(t)
|
|
case "background rate limit":
|
|
quota := &banking.BackgroundQuotaError{RateLimitError: ratelimit.NewError(time.Now().Add(6 * time.Hour))}
|
|
b.fetchErr = fmt.Errorf("private provider response: %w", quota)
|
|
case "reconnect":
|
|
b.fetchErr = fmt.Errorf("private provider response: %w", banking.ErrReconnect)
|
|
case "private response":
|
|
b.fetchErr = errors.New("private provider response")
|
|
case "unusable response":
|
|
unusable := &banking.ProviderError{Detail: "a booked transaction has no valid booking date"}
|
|
b.fetchErr = fmt.Errorf("private provider response: %w", unusable)
|
|
}
|
|
failed, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := a.ops.Consents["current"]
|
|
if failed.Status.SyncError == "" || meta.Error == "" || strings.Contains(failed.Status.SyncError+meta.Error, "private provider response") {
|
|
t.Fatal("transaction failure was lost or exposed provider data")
|
|
}
|
|
if strings.Contains(scenario, "rate limit") && (!strings.Contains(failed.Status.SyncError, "429") || !strings.Contains(meta.Error, "429") || !strings.Contains(meta.Error, "retry")) {
|
|
t.Fatal("transaction rate error was obscured")
|
|
}
|
|
if scenario == "background rate limit" && !strings.Contains(meta.Error, "background") {
|
|
t.Fatal("daily bank quota was confused with a short request throttle")
|
|
}
|
|
if meta.NeedsReconnect != (scenario == "reconnect") {
|
|
t.Fatal("transaction failure classified consent incorrectly")
|
|
}
|
|
if !reflect.DeepEqual(s.Data, failed.Data) || !reflect.DeepEqual(cursors, a.ops.AccountSync) || a.ops.LastSync != old {
|
|
t.Fatal("failed retrieval imported partial data or advanced synchronization")
|
|
}
|
|
rateLimited := strings.Contains(scenario, "rate limit")
|
|
// A bank that named its own retry time is a wait, not a fault: the
|
|
// UI and the scheduler both rely on this distinction.
|
|
if (failed.Status.SyncRetryAt != "") != rateLimited {
|
|
t.Fatalf("waiting state is wrong for %s: retry at %q", scenario, failed.Status.SyncRetryAt)
|
|
}
|
|
for _, connection := range failed.Connections {
|
|
if connection.Status == "local" {
|
|
continue
|
|
}
|
|
want := "error"
|
|
switch {
|
|
case rateLimited:
|
|
want = "rate_limited"
|
|
case scenario == "reconnect":
|
|
want = "reconnect_required"
|
|
}
|
|
if connection.Status != want || (connection.RetryAt != "") != rateLimited {
|
|
t.Fatalf("connection reported %q with retry %q, want %q", connection.Status, connection.RetryAt, want)
|
|
}
|
|
}
|
|
if rateLimited {
|
|
at, e := time.Parse(time.RFC3339, failed.Status.SyncRetryAt)
|
|
if e != nil || !at.After(time.Now()) {
|
|
t.Fatalf("unusable retry deadline %q: %v", failed.Status.SyncRetryAt, e)
|
|
}
|
|
// Whole seconds, once: operators read this message.
|
|
if !strings.Contains(meta.Error, at.UTC().Format(time.RFC3339)) || strings.Count(meta.Error, "429") != 1 {
|
|
t.Fatalf("rate limit message is not legible: %q", meta.Error)
|
|
}
|
|
}
|
|
if scenario == "unusable response" && (!strings.Contains(meta.Error, "cannot use") || !strings.Contains(meta.Error, "booking date")) {
|
|
t.Fatalf("unusable provider response was reduced to an opaque failure: %q", meta.Error)
|
|
}
|
|
if scenario == "private response" && !strings.Contains(meta.Error, "transaction retrieval failed") {
|
|
t.Fatalf("unrecognized failure lost its fallback: %q", meta.Error)
|
|
}
|
|
b.fetchErr = nil
|
|
recovered, err := a.Sync(context.Background())
|
|
if err != nil || recovered.Status.SyncError != "" || a.ops.Consents["current"].Error != "" || a.ops.Consents["current"].NeedsReconnect {
|
|
t.Fatal("successful retrieval did not clear the failure")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The scheduler must not spend session-status calls on a refusal the bank has
|
|
// already scheduled, and must not sit on a deadline that has passed.
|
|
func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) {
|
|
// Persisted deadlines carry whole seconds; compare against the same grid.
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
stale := now.Add(-48 * time.Hour).Format(time.RFC3339)
|
|
waiting := operational{LastSync: stale, SyncError: "N26: rate limited", SyncRetryAt: now.Add(3 * time.Hour).Format(time.RFC3339)}
|
|
broken := operational{LastSync: stale, SyncError: "Trade Republic: retrieval failed"}
|
|
healthy := operational{LastSync: now.Format(time.RFC3339)}
|
|
// Twice daily: still fresh at six hours, due again after thirteen.
|
|
fresh := operational{LastSync: now.Add(-6 * time.Hour).Format(time.RFC3339)}
|
|
overdue := operational{LastSync: now.Add(-13 * time.Hour).Format(time.RFC3339)}
|
|
elapsed := operational{LastSync: stale, SyncError: waiting.SyncError, SyncRetryAt: now.Add(-time.Minute).Format(time.RFC3339)}
|
|
cases := []struct {
|
|
name string
|
|
ops operational
|
|
force bool
|
|
wait time.Duration
|
|
due bool
|
|
}{
|
|
{"waiting for the bank", waiting, false, time.Hour, false},
|
|
{"manual sync during a wait", waiting, true, 0, true},
|
|
{"deadline elapsed", elapsed, false, 0, true},
|
|
{"failure without a deadline", broken, false, 0, true},
|
|
{"recent success", healthy, false, time.Minute, false},
|
|
{"halfway through the interval", fresh, false, time.Minute, false},
|
|
{"interval elapsed", overdue, false, 0, true},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
wait, due := syncSchedule(now, tt.ops, tt.force)
|
|
if wait != tt.wait || due != tt.due {
|
|
t.Fatalf("schedule = (%s, %t), want (%s, %t)", wait, due, tt.wait, tt.due)
|
|
}
|
|
})
|
|
}
|
|
if backoff := syncBackoff(now, waiting); backoff != 3*time.Hour+time.Minute {
|
|
t.Fatalf("rate-limited backoff = %s, want the bank's own deadline", backoff)
|
|
}
|
|
if backoff := syncBackoff(now, broken); backoff != time.Hour {
|
|
t.Fatalf("failure backoff = %s, want hourly retries", backoff)
|
|
}
|
|
if backoff := syncBackoff(now, healthy); backoff != 12*time.Hour {
|
|
t.Fatalf("successful backoff = %s, want twice-daily synchronization", backoff)
|
|
}
|
|
}
|