package zenmoney import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" ) // fixtureServer replays testdata/diff.json and records the request, so the // tests can assert the wire contract (Bearer token, POST, /v8/diff/) without a // ZenMoney account. func fixtureServer(t *testing.T, got *diffRequest, auth *string) *httptest.Server { t.Helper() body, err := os.ReadFile("testdata/diff.json") if err != nil { t.Fatal(err) } return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method = %s, want POST", r.Method) } if r.URL.Path != "/v8/diff/" { t.Errorf("path = %s, want /v8/diff/", r.URL.Path) } if auth != nil { *auth = r.Header.Get("Authorization") } if got != nil { if err := json.NewDecoder(r.Body).Decode(got); err != nil { t.Errorf("decode request: %v", err) } } w.Header().Set("Content-Type", "application/json") w.Write(body) })) } func aug(day int) time.Time { return time.Date(2026, 8, day, 0, 0, 0, 0, time.UTC) } func TestSinceSumsSpendingPerCurrency(t *testing.T) { var req diffRequest var auth string srv := fixtureServer(t, &req, &auth) defer srv.Close() c, err := New("tok", srv.URL, time.Second) if err != nil { t.Fatal(err) } s, err := c.Since(context.Background(), aug(1), aug(6)) if err != nil { t.Fatal(err) } if auth != "Bearer tok" { t.Errorf("Authorization = %q", auth) } if req.ServerTimestamp != aug(1).Unix() { t.Errorf("serverTimestamp = %d, want the window start", req.ServerTimestamp) } // 1500 + 249.5 RUB spent, 12 EUR spent, 3000 RUB in. The transfer (t4), the // deleted row (t6) and July's salary (t3) are all excluded. want := map[string]float64{"RUB": 1749.5, "EUR": 12} if len(s.Spent) != 2 { t.Fatalf("spent = %+v, want two currencies", s.Spent) } for _, m := range s.Spent { if want[m.Currency] != m.Amount { t.Errorf("spent %s = %v, want %v", m.Currency, m.Amount, want[m.Currency]) } } if len(s.Earned) != 1 || s.Earned[0].Amount != 3000 || s.Earned[0].Currency != "RUB" { t.Errorf("earned = %+v, want 3000 RUB (July's salary is outside the window)", s.Earned) } if s.Count != 4 { t.Errorf("count = %d, want 4 counted transactions", s.Count) } // Largest first, so the fact value does not churn between polls. if s.Spent[0].Currency != "RUB" { t.Errorf("spent order = %+v, want the largest amount first", s.Spent) } } // A window with nothing in it is NOT a zero. No transactions means no answer, // and the caller must be able to tell the difference. func TestSinceEmptyWindowIsNotAZero(t *testing.T) { srv := fixtureServer(t, nil, nil) defer srv.Close() c, _ := New("tok", srv.URL, time.Second) s, err := c.Since(context.Background(), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } if !s.Empty() { t.Fatalf("summary = %+v, want empty", s) } if _, ok := s.Value(time.Now()); ok { t.Error("an empty summary must not produce a fact value") } } func TestSinceReportsHTTPFailureWithoutTheBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"account":"acc-card","secret":"leaky"}`, http.StatusUnauthorized) })) defer srv.Close() c, _ := New("tok", srv.URL, time.Second) _, err := c.Since(context.Background(), aug(1), aug(6)) if err == nil { t.Fatal("want an error on 401") } if strings.Contains(err.Error(), "acc-card") || strings.Contains(err.Error(), "leaky") { t.Errorf("error %q echoes the response body — it reaches the log", err) } } func TestNewRequiresAToken(t *testing.T) { if _, err := New(" ", "", 0); err == nil { t.Error("want an error for an empty token — the capability is off unless configured") } } func TestMonthAndDayWindows(t *testing.T) { now := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) from, to := MonthWindow(now) if from != time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) { t.Errorf("month window = %v..%v", from, to) } from, to = DayWindow(now) if from != time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) { t.Errorf("day window = %v..%v", from, to) } } func TestFactValueRoundTripAndFormat(t *testing.T) { s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3} raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) if !ok { t.Fatal("want a fact value") } v, err := ParseFactValue(raw) if err != nil { t.Fatal(err) } got := v.FormatRU("в этом месяце") if !strings.Contains(got, "1749.5 RUB") || !strings.Contains(got, "3000 RUB") { t.Errorf("reply = %q, want the exact figures", got) } // Persona: informal, feminine, no commentary on his spending. for _, bad := range []string{"вы", "ваш", "милый", "дорогой", "рад ", "слишком", "много"} { if strings.Contains(got, bad) { t.Errorf("reply %q contains %q", got, bad) } } if strings.Contains(got, "он ") { t.Errorf("reply %q talks about him in the third person", got) } } // An empty fact value renders to nothing, so a caller cannot accidentally // speak a zero. func TestFormatRUEmptyRendersNothing(t *testing.T) { if got := (FactValue{}).FormatRU("сегодня"); got != "" { t.Errorf("reply = %q, want empty", got) } } func TestFormatAmountKeepsTheTruth(t *testing.T) { for in, want := range map[float64]string{1500: "1500", 249.5: "249.5", 0.99: "0.99", 1749.55: "1749.55"} { if got := formatAmount(in); got != want { t.Errorf("formatAmount(%v) = %q, want %q", in, got, want) } } } // The day window rolls over at midnight and the first spend of the new day may // be hours away, so the last good money_today fact keeps a fresh ts while // covering yesterday. Only the window stamp inside the value can tell. func TestFactValueCoversDay(t *testing.T) { from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1} raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)) if !ok { t.Fatal("want a fact value") } v, err := ParseFactValue(raw) if err != nil { t.Fatal(err) } if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) { t.Error("the same day must be covered") } if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) { t.Error("yesterday's day total must not count as today's") } if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) { t.Error("a value with no window stamp must fail closed") } if v.AsOf.IsZero() { t.Error("the value must carry when it was read, not only when it changed") } } // The instrument rows are only in the diff when they changed since the cursor, // which for a day window they usually have not. An amount whose currency // nothing could name is dropped from the spoken string rather than read out // as "1749.5 ?". func TestUnknownCurrencyIsNotSpoken(t *testing.T) { v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1} if got := v.FormatRU("сегодня"); got != "" { t.Errorf("reply = %q, want nothing said about an unlabelled amount", got) } v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2} got := v.FormatRU("сегодня") if strings.Contains(got, UnknownCurrency) { t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency) } if !strings.Contains(got, "100 RUB") { t.Errorf("reply = %q, want the amount that does have a currency", got) } } // A day diff cursored at midnight usually carries no instrument rows at all. // The client fetches them once from a cursor-zero diff instead of labelling // every amount "?". func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) { body, err := os.ReadFile("testdata/diff.json") if err != nil { t.Fatal(err) } var full diffResponse if err := json.Unmarshal(body, &full); err != nil { t.Fatal(err) } windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction} instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument} zeroCursorCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req diffRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode request: %v", err) } out := windowed if req.ServerTimestamp == 0 { zeroCursorCalls++ out = instrumentsOnly } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) })) defer srv.Close() c, _ := New("tok", srv.URL, time.Second) s, err := c.Since(context.Background(), aug(1), aug(6)) if err != nil { t.Fatal(err) } for _, m := range s.Spent { if m.Currency == UnknownCurrency { t.Fatalf("spent = %+v, want every amount named", s.Spent) } } // Cached for the process lifetime: a second window does not refetch. if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil { t.Fatal(err) } if zeroCursorCalls != 1 { t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls) } }