da647e87d0
The trust boundary is zenmoney, not maven — they already hold his bank sessions. So the poller reads /v8/diff/ and writes totals as facts(kind=env, source=poll:zenmoney); core reads those back when he asks and never sees the token. internal/zenmoney sums transactions per currency over a window, skipping tombstoned rows and transfers between his own accounts, and refuses to encode a summary built from zero transactions. That refusal is the whole design: a failed or empty read writes nothing and leaves the last good total alone, because a zero recited as fact is worse than silence. No currency conversion either — a figure he can check against his bank beats one he cannot. Off unless configured, and the token is read from a FILE rather than a flag so it never lands in `ps`, in docker-compose.yml, or in shell history. Nothing about the money is search input, no tick rule reads the keys, and the log lines name keys, never figures. The live-credential half is BLOCKED: there is no zenmoney account or token here, so everything is verified against a recorded diff fixture.
179 lines
5.6 KiB
Go
179 lines
5.6 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(); 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()
|
|
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)
|
|
}
|
|
}
|
|
}
|