Files
kami 6316354518 zenmoney: bound the day fact to its own day and stamp when it was read
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.
2026-08-01 14:16:56 +04:00

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)
}
}