Files
tiennm99bot/internal/storage/memory_doc_store_test.go
tiennm99 8260d2860b fix(alias): answer inline queries from one store read under a deadline
Telegram expires an inline query and then rejects the answer with "query
is too old and response timeout expired or query ID is invalid". The
picker invited that: it listed the names and then read the store once per
name — up to 50 round trips per keystroke — and it was the only handler
in the module with no deadline of its own. Updates are dispatched one at
a time, so a single slow answer also held up the queries queued behind
it, each ageing while it waited, and one slow read expired a whole burst
of typing.

Add DocStore.Scan, which reads a key prefix with its values in one round
trip, ordered by key. The picker and /aliases both use it, so neither
grows a round trip per saved alias. Bound the inline handler at 3s: an
answer later than that is rejected anyway, and giving up frees the worker
for the fresher query behind it. When Telegram does reject an answer, the
error now carries how long it took, which separates a slow handler from a
query that was already stale on arrival.

The 50-result cap now counts results the picker can show, so a video-note
alias — which has no cached inline type — no longer consumes a slot.
2026-09-08 16:02:39 +07:00

188 lines
5.5 KiB
Go

package storage
import (
"context"
"errors"
"testing"
)
type testPayload struct {
Name string `json:"name" bson:"name"`
Count int `json:"count" bson:"count"`
}
func memStore(module string) DocStore[testPayload] {
return Typed[testPayload](NewMemoryProvider().Collection(module))
}
func TestMemoryDocStore_PutGetRoundTrip(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
if err := s.Put(ctx, "k", testPayload{Name: "a", Count: 3}); err != nil {
t.Fatalf("Put: %v", err)
}
got, version, err := s.Get(ctx, "k")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Name != "a" || got.Count != 3 {
t.Fatalf("got %+v", got)
}
if version != 1 {
t.Fatalf("version = %d, want 1", version)
}
}
func TestMemoryDocStore_GetMissing(t *testing.T) {
if _, _, err := memStore("coin").Get(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get missing = %v, want ErrNotFound", err)
}
}
func TestMemoryDocStore_PutBumpsVersion(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
_ = s.Put(ctx, "k", testPayload{Count: 1})
_ = s.Put(ctx, "k", testPayload{Count: 2})
_, version, _ := s.Get(ctx, "k")
if version != 2 {
t.Fatalf("version = %d, want 2", version)
}
}
func TestMemoryDocStore_PutVersionedCAS(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
// create
if err := s.PutVersioned(ctx, "k", 0, testPayload{Count: 1}); err != nil {
t.Fatalf("create: %v", err)
}
// second create must conflict
if err := s.PutVersioned(ctx, "k", 0, testPayload{Count: 9}); !errors.Is(err, ErrConflict) {
t.Fatalf("double create = %v, want ErrConflict", err)
}
// stale expected version conflicts
if err := s.PutVersioned(ctx, "k", 99, testPayload{Count: 9}); !errors.Is(err, ErrConflict) {
t.Fatalf("stale CAS = %v, want ErrConflict", err)
}
// fresh version succeeds
if err := s.PutVersioned(ctx, "k", 1, testPayload{Count: 2}); err != nil {
t.Fatalf("fresh CAS: %v", err)
}
}
func TestMemoryDocStore_DeleteIdempotent(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
_ = s.Put(ctx, "k", testPayload{})
if err := s.Delete(ctx, "k"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := s.Delete(ctx, "k"); err != nil {
t.Fatalf("Delete missing must be nil, got %v", err)
}
}
func TestMemoryDocStore_ListPrefix(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
_ = s.Put(ctx, "game:1", testPayload{})
_ = s.Put(ctx, "game:2", testPayload{})
_ = s.Put(ctx, "stats:1", testPayload{})
keys, err := s.List(ctx, "game:")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(keys) != 2 || keys[0] != "game:1" || keys[1] != "game:2" {
t.Fatalf("List game: = %v", keys)
}
}
func TestMemoryDocStore_ValueIsolation(t *testing.T) {
ctx := context.Background()
s := Typed[testPayloadWithSlice](NewMemoryProvider().Collection("coin"))
in := testPayloadWithSlice{Items: []string{"x"}}
_ = s.Put(ctx, "k", in)
in.Items[0] = "mutated" // mutating the caller copy must not affect stored state
got, _, _ := s.Get(ctx, "k")
if got.Items[0] != "x" {
t.Fatalf("stored value aliased caller slice: %v", got.Items)
}
}
type testPayloadWithSlice struct {
Items []string `json:"items" bson:"items"`
}
func TestCheckReservedFields(t *testing.T) {
type bad struct {
Version int `bson:"version"`
}
if err := checkReservedFields[bad](); err == nil {
t.Fatal("expected reserved-field collision error for bson:\"version\"")
}
if err := checkReservedFields[testPayload](); err != nil {
t.Fatalf("clean struct flagged: %v", err)
}
// Non-struct payloads are skipped, not rejected.
if err := checkReservedFields[string](); err != nil {
t.Fatalf("string payload flagged: %v", err)
}
}
// Scan is the batched read: keys and values together, in key order, so a
// caller never follows List with a Get per key.
func TestMemoryDocStore_ScanReturnsValuesInKeyOrder(t *testing.T) {
ctx := context.Background()
s := memStore("alias")
_ = s.Put(ctx, "cheese", testPayload{Name: "second", Count: 2})
_ = s.Put(ctx, "boo", testPayload{Name: "first", Count: 1})
_ = s.Put(ctx, "other", testPayload{Name: "third", Count: 3})
docs, err := s.Scan(ctx, "")
if err != nil {
t.Fatalf("Scan: %v", err)
}
want := []Doc[testPayload]{
{ID: "boo", Val: testPayload{Name: "first", Count: 1}},
{ID: "cheese", Val: testPayload{Name: "second", Count: 2}},
{ID: "other", Val: testPayload{Name: "third", Count: 3}},
}
if len(docs) != len(want) {
t.Fatalf("Scan returned %d docs, want %d: %+v", len(docs), len(want), docs)
}
for i := range want {
if docs[i] != want[i] {
t.Errorf("docs[%d] = %+v, want %+v", i, docs[i], want[i])
}
}
}
func TestMemoryDocStore_ScanPrefix(t *testing.T) {
ctx := context.Background()
s := memStore("coin")
_ = s.Put(ctx, "game:1", testPayload{Name: "a"})
_ = s.Put(ctx, "game:2", testPayload{Name: "b"})
_ = s.Put(ctx, "stats:1", testPayload{Name: "c"})
docs, err := s.Scan(ctx, "game:")
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(docs) != 2 || docs[0].ID != "game:1" || docs[1].ID != "game:2" {
t.Fatalf("Scan game: = %+v", docs)
}
if docs[0].Val.Name != "a" || docs[1].Val.Name != "b" {
t.Errorf("Scan lost payloads: %+v", docs)
}
}
// An empty collection yields no docs and no error — the picker path answers
// with nothing rather than treating it as a failure.
func TestMemoryDocStore_ScanEmpty(t *testing.T) {
docs, err := memStore("alias").Scan(context.Background(), "")
if err != nil || len(docs) != 0 {
t.Fatalf("Scan on empty store = %+v, err %v", docs, err)
}
}