Files
tiennm99bot/cmd/server/main_test.go
T
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

212 lines
7.2 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
func TestResolveCommitSHA(t *testing.T) {
prev := gitSHA
defer func() { gitSHA = prev }()
// Coolify runtime env wins over the baked fallback.
gitSHA = "baked"
if got := resolveCommitSHA(" runtime-sha "); got != "runtime-sha" {
t.Errorf("env present: got %q, want trimmed runtime-sha", got)
}
// Empty env falls back to the VCS revision embedded by go build.
if got := resolveCommitSHA(""); got != "baked" {
t.Errorf("env empty: got %q, want baked fallback", got)
}
// Neither source set → "unknown" so the owner still gets the startup DM.
gitSHA = ""
if got := resolveCommitSHA(" "); got != "unknown" {
t.Errorf("both empty: got %q, want unknown", got)
}
}
func TestShortCommitSHA(t *testing.T) {
if got := shortCommitSHA(" 0123456789abcdef "); got != "0123456" {
t.Errorf("full revision: got %q, want %q", got, "0123456")
}
if got := shortCommitSHA("abc123"); got != "abc123" {
t.Errorf("short revision: got %q, want %q", got, "abc123")
}
}
func TestComposeDoesNotOverrideSourceCommit(t *testing.T) {
b, err := os.ReadFile(filepath.Join("..", "..", "compose.yml"))
if err != nil {
t.Fatalf("read compose.yml: %v", err)
}
for _, line := range strings.Split(string(b), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "SOURCE_COMMIT:") || strings.HasPrefix(trimmed, "- SOURCE_COMMIT") {
t.Fatalf("compose.yml must not declare SOURCE_COMMIT; Coolify supplies it at runtime and an explicit Compose value can override it with empty")
}
}
}
func TestFactoriesIncludesExpectedModules(t *testing.T) {
catalog := factories()
for _, name := range []string{"gold", "coin"} {
if catalog[name] == nil {
t.Fatalf("factories missing %s", name)
}
}
reg, err := modules.Build([]string{"gold", "coin"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build selected modules: %v", err)
}
for _, name := range []string{
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio",
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio",
} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
}
func TestFactoriesRegistersBlacklistCommands(t *testing.T) {
catalog := factories()
if catalog["blacklist"] == nil {
t.Fatal("factories missing blacklist")
}
reg, err := modules.Build([]string{"blacklist"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build blacklist: %v", err)
}
for _, name := range []string{
"blacklist", "blacklist_add", "blacklist_del", "blacklist_rules",
"blacklist_check", "whitelist_add", "whitelist_del", "whitelist_rnd",
} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if got := len(reg.AllCommands); got != 8 {
t.Fatalf("blacklist registered %d commands, want 8", got)
}
}
// noitu is in the catalog and, with GAME_BASE_URL unset, registers its commands
// and game but no public HTTP route.
func TestFactoriesRegistersNoituDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("NOITU_GAME_SECRET", "")
reg, err := modules.Build([]string{"noitu"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build noitu: %v", err)
}
for _, name := range []string{"noitu", "noitubot", "noitutop"} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if _, ok := reg.AllCommands["noitupvp"]; ok {
t.Fatal("renamed command noitupvp is still registered")
}
if routes := serverRoutes(reg); len(routes) != 0 {
t.Fatalf("disabled game exposes routes: %+v", routes)
}
}
// wordledaily is in the catalog and, with GAME_BASE_URL unset, registers its
// commands and game but no public HTTP route and no daily push.
func TestFactoriesRegistersWordleDailyDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("WORDLEDAILY_GAME_SECRET", "")
reg, err := modules.Build([]string{"wordledaily"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build wordledaily: %v", err)
}
for _, name := range []string{"wordledaily", "wordledaily_subscribe", "wordledaily_unsubscribe"} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if routes := serverRoutes(reg); len(routes) != 0 {
t.Fatalf("disabled game exposes routes: %+v", routes)
}
for _, m := range reg.Modules {
if len(m.Crons) != 0 {
t.Fatalf("disabled game registers crons: %+v", m.Crons)
}
}
}
// loldle is in the catalog and, with GAME_BASE_URL unset, registers its
// unlimited and daily commands and its game, but no route and no cron.
func TestFactoriesRegistersLoldleDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("LOLDLE_GAME_SECRET", "")
reg, err := modules.Build([]string{"loldle"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build loldle: %v", err)
}
for _, name := range []string{"loldle", "loldle_giveup", "loldle_stats", "loldle_setmax", "loldledaily", "loldledaily_subscribe", "loldledaily_unsubscribe"} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if routes := serverRoutes(reg); len(routes) != 0 {
t.Fatalf("disabled game exposes routes: %+v", routes)
}
for _, m := range reg.Modules {
if len(m.Crons) != 0 {
t.Fatalf("disabled game registers crons: %+v", m.Crons)
}
}
}
// The in-chat wordle module was folded into wordledaily: a MODULES value
// naming it loads wordledaily, once, with every /wordle command.
func TestNormalizeModules_MapsRetiredWordleToWordleDaily(t *testing.T) {
for in, want := range map[string]string{
"wordle": "wordledaily",
"util,wordle,loldle": "util wordledaily loldle",
"wordle,wordledaily,misc": "wordledaily misc",
"wordledaily,wordle": "wordledaily",
} {
if got := strings.Join(normalizeModules(splitCSV(in)), " "); got != want {
t.Errorf("normalizeModules(%q) = %q, want %q", in, got, want)
}
}
if normalizeModules(nil) != nil {
t.Fatal("empty MODULES must stay empty: it loads every module")
}
t.Setenv("GAME_BASE_URL", "")
reg, err := modules.Build(normalizeModules([]string{"wordle"}), factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
for _, name := range []string{"wordle", "wordle_new", "wordle_giveup", "wordle_stats", "wordledaily"} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if _, ok := factories()["wordle"]; ok {
t.Fatal("the retired wordle module is still in the catalog")
}
}
// An empty MODULES loads every module, so a command name that collides with an
// existing module surfaces here as a test failure rather than as a startup
// crash on deploy.
func TestFactoriesBuildWholeCatalog(t *testing.T) {
if _, err := modules.Build(nil, factories(), storage.NewMemoryProvider(), modules.BuildOptions{}); err != nil {
t.Fatalf("Build whole catalog: %v", err)
}
}