6316354518
The day total rolls over at midnight and the poller had nothing to write until the first spend of the new day, so at 09:00 the latest money_today fact was yesterday's spending and looked perfectly fresh. The value now carries the first instant of the window it covers, and a today question that the stored window does not cover is refused rather than answered with yesterday's number. Staleness was measured off the fact timestamp, which only moved when the figure moved, so a quiet month was reported as data from three days ago while being current. The value now carries when it was last read and the poller writes on every read. Amounts in an instrument the window diff never named were spoken with a numeric instrument id as the currency. Instruments are resolved from one cursor-zero diff, cached for the process, and an amount still unnamed is dropped from speech rather than recited wrongly. "сколько я потратил вчера" was answered with the month total, a real number to a different question, and is now refused by naming the two windows she keeps. Income questions led with the spending. Found in review of #62.
275 lines
9.1 KiB
Go
275 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|