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.
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/zenmoney"
|
|
)
|
|
|
|
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
|
// which is the assertion that answering a money question costs no model call
|
|
// and reaches no network.
|
|
type moneyAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
fact ipc.Fact
|
|
err error
|
|
gotKey string
|
|
gotSrc string
|
|
callCnt int
|
|
}
|
|
|
|
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
|
a.gotKey, a.gotSrc = key, source
|
|
a.callCnt++
|
|
return a.fact, a.err
|
|
}
|
|
|
|
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
|
|
|
func moneyFact(ts time.Time, val string) ipc.Fact {
|
|
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
|
}
|
|
|
|
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
|
h := &reactiveHandler{api: api, now: moneyNow}
|
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("the money source must claim a money question")
|
|
}
|
|
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
|
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
|
}
|
|
if !strings.Contains(reply, "1749.5") {
|
|
t.Errorf("reply = %q, want the exact figure", reply)
|
|
}
|
|
if !strings.Contains(reply, "в этом месяце") {
|
|
t.Errorf("reply = %q, want the window named", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
|
h := &reactiveHandler{api: api, now: moneyNow}
|
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
|
}); !ok {
|
|
t.Fatal("expected the source to claim it")
|
|
}
|
|
if api.gotKey != zenmoney.KeySpentToday {
|
|
t.Errorf("key = %q, want today's", api.gotKey)
|
|
}
|
|
}
|
|
|
|
// The capability is off unless configured, and then there is no fact. She says
|
|
// so instead of letting the recall pass answer a money question from a note.
|
|
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
|
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected the source to claim it")
|
|
}
|
|
if !strings.Contains(reply, "не подключено") {
|
|
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
|
}
|
|
// No number of any kind in that answer.
|
|
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
|
if strings.Contains(reply, d) {
|
|
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A fact older than the staleness bound is dated rather than spoken as if it
|
|
// were current: the poller can be down, and last week's total presented as
|
|
// today's is a lie by omission.
|
|
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
|
old := moneyNow().Add(-72 * time.Hour)
|
|
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
|
h := &reactiveHandler{api: api, now: moneyNow}
|
|
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
|
})
|
|
if !strings.Contains(reply, "данные от") {
|
|
t.Errorf("reply = %q, want the stale fact dated", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
|
api := &moneyAPI{}
|
|
h := &reactiveHandler{api: api, now: moneyNow}
|
|
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
|
t.Errorf("the money source claimed %q", u)
|
|
}
|
|
}
|
|
if api.callCnt != 0 {
|
|
t.Error("a non-money question must not read the money facts")
|
|
}
|
|
}
|
|
|
|
// Money must be answered before the recall sources, or a question about
|
|
// spending gets answered by the nearest note.
|
|
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
|
moneyAt, notesAt := -1, -1
|
|
for i, src := range querySources {
|
|
switch src.name {
|
|
case "money":
|
|
moneyAt = i
|
|
case "notes":
|
|
notesAt = i
|
|
}
|
|
}
|
|
if moneyAt < 0 || notesAt < 0 {
|
|
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
|
}
|
|
if moneyAt > notesAt {
|
|
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
|
}
|
|
}
|