Files
scrabble-game/platform/telegram/internal/support/store_test.go
T
Ilia Denisov 6a602aefae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
feat(telegram): bot support relay — per-user forum topics
Users who DM the bot anything but /start are relayed into a private
forum supergroup, one topic per user. Operators (the chat's admins)
reply in the topic and the bot copies it back to the user; an info card
opening each topic carries a Block/Unblock toggle and a Clear button.
State is a small JSON file on a new /data volume — the bot host has no
database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is
unchanged until the operator sets the chat id and adds the bot as a
forum admin.

- internal/support: concurrency-safe JSON store (topic map, block list,
  relayed message ids) with field-targeted mutators and atomic save
- bot/support.go: relay both ways via copyMessage, short-TTL admin
  cache, callback buttons, per-user topic-create lock, loop guard
  (skip the bot's own posts), reopen a deleted topic on the next message
- config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per
  contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data
  pre-owned by UID 65532 so a fresh volume is writable under distroless
- docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
2026-06-23 17:47:00 +02:00

179 lines
4.9 KiB
Go

package support
import (
"os"
"path/filepath"
"sync"
"testing"
)
// statePath returns a path inside a fresh temp directory for a store file.
func statePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "support.json")
}
func TestOpenMissingReturnsEmpty(t *testing.T) {
s, err := Open(statePath(t))
if err != nil {
t.Fatalf("Open missing: %v", err)
}
if _, ok := s.Get(42); ok {
t.Error("Get on an empty store returned a record")
}
}
func TestOpenCorruptReturnsError(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("write corrupt: %v", err)
}
if _, err := Open(path); err == nil {
t.Fatal("Open on a corrupt file: expected an error, got nil")
}
}
func TestSetTopicPersistsAndReloads(t *testing.T) {
path := statePath(t)
s := New(path)
if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
// In-memory view.
got, ok := s.Get(7)
if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" {
t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok)
}
if got.CreatedAt.IsZero() {
t.Error("CreatedAt not set on first contact")
}
if uid, ok := s.ByTopic(100); !ok || uid != 7 {
t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
// Reloaded from disk.
reload, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, ok = reload.Get(7)
if !ok || got.TopicID != 100 || got.FirstName != "Ann" {
t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok)
}
if uid, ok := reload.ByTopic(100); !ok || uid != 7 {
t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
}
func TestSetTopicRecreateReindexes(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if _, ok := s.ByTopic(100); ok {
t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped")
}
if uid, ok := s.ByTopic(200); !ok || uid != 7 {
t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok)
}
}
// TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate
// (SetTopic) must not clear a block set concurrently from the buttons.
func TestBlockSurvivesTopicRecreate(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetBlocked(7, true); err != nil {
t.Fatalf("SetBlocked: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if !s.Blocked(7) {
t.Error("block was cleared by a topic recreate")
}
}
func TestAppendAndClearMsgs(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
for _, id := range []int{500, 501, 502} {
if err := s.AppendMsg(7, id); err != nil {
t.Fatalf("AppendMsg %d: %v", id, err)
}
}
got, _ := s.Get(7)
if len(got.RelayedMsgIDs) != 3 {
t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs)
}
ids, err := s.ClearMsgs(7)
if err != nil {
t.Fatalf("ClearMsgs: %v", err)
}
if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 {
t.Errorf("ClearMsgs = %v, want [500 501 502]", ids)
}
got, _ = s.Get(7)
if len(got.RelayedMsgIDs) != 0 {
t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs)
}
if got.HeaderMsgID != 101 || got.TopicID != 100 {
t.Errorf("clear disturbed the topic/header: %+v", got)
}
// A second clear is a no-op.
if ids, err := s.ClearMsgs(7); err != nil || ids != nil {
t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err)
}
}
func TestGetReturnsDetachedCopy(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.AppendMsg(7, 500); err != nil {
t.Fatalf("AppendMsg: %v", err)
}
got, _ := s.Get(7)
got.RelayedMsgIDs[0] = 999 // mutate the copy
again, _ := s.Get(7)
if again.RelayedMsgIDs[0] != 500 {
t.Error("Get returned a slice that aliases the store's state")
}
}
// TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with
// -race to catch data races.
func TestConcurrentMutationsSafe(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = s.AppendMsg(7, 1000+n)
_ = s.SetBlocked(7, n%2 == 0)
_, _ = s.Get(7)
_, _ = s.ByTopic(100)
}(i)
}
wg.Wait()
got, ok := s.Get(7)
if !ok || len(got.RelayedMsgIDs) != 20 {
t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs))
}
}