mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
Implements Phases 02 (partial) and 03 of the go-port-cloud-run plan. Introduces module framework with per-module KV prefix isolation, health check endpoint, request timeout protection, and comprehensive test coverage. Cloud Run deployment deferred to Phase 01. Security hardening: constant-time secret comparison, cron auth bridge, and secrets stripped from dependency environment exports. Includes Dockerfile, GitHub CI workflow (vet + race + build), and integration tests for module lifecycle.
270 lines
7.5 KiB
Go
270 lines
7.5 KiB
Go
package modules
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"github.com/tiennm99/miti99bot-go/internal/storage"
|
|
)
|
|
|
|
func noopCmd(name string) Command {
|
|
return Command{
|
|
Name: name,
|
|
Visibility: VisibilityPublic,
|
|
Description: "test " + name,
|
|
Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil },
|
|
}
|
|
}
|
|
|
|
func noopCron(name string) Cron {
|
|
return Cron{
|
|
Schedule: "@every 24h",
|
|
Name: name,
|
|
Handler: func(_ context.Context, _ Deps) error { return nil },
|
|
}
|
|
}
|
|
|
|
func factory(name string, cmds []Command, crons []Cron) Factory {
|
|
return func(_ Deps) Module {
|
|
return Module{Name: name, Commands: cmds, Crons: crons}
|
|
}
|
|
}
|
|
|
|
func baseDeps() Deps { return Deps{KV: storage.NewMemoryKVStore()} }
|
|
|
|
func TestBuild_EmptyModulesBootsCleanly(t *testing.T) {
|
|
reg, err := Build(nil, map[string]Factory{}, baseDeps())
|
|
if err != nil {
|
|
t.Fatalf("Build empty: %v", err)
|
|
}
|
|
if len(reg.AllCommands) != 0 {
|
|
t.Errorf("expected 0 commands, got %d", len(reg.AllCommands))
|
|
}
|
|
}
|
|
|
|
func TestBuild_LoadsRequestedModules(t *testing.T) {
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
|
"beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}),
|
|
}
|
|
reg, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if len(reg.Modules) != 2 {
|
|
t.Errorf("expected 2 modules, got %d", len(reg.Modules))
|
|
}
|
|
if _, ok := reg.AllCommands["a1"]; !ok {
|
|
t.Error("missing command a1")
|
|
}
|
|
if _, ok := reg.Cron("daily"); !ok {
|
|
t.Error("missing cron daily")
|
|
}
|
|
}
|
|
|
|
func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
|
"beta": factory("beta", []Command{noopCmd("b1")}, nil),
|
|
}
|
|
reg, err := Build([]string{"alpha"}, factories, baseDeps())
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if _, ok := reg.AllCommands["b1"]; ok {
|
|
t.Error("beta should not have been loaded")
|
|
}
|
|
}
|
|
|
|
func TestBuild_RejectsUnknownModule(t *testing.T) {
|
|
_, err := Build([]string{"ghost"}, map[string]Factory{}, baseDeps())
|
|
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
|
t.Errorf("expected error mentioning ghost, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuild_DetectsCommandConflict(t *testing.T) {
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", []Command{noopCmd("ping")}, nil),
|
|
"beta": factory("beta", []Command{noopCmd("ping")}, nil),
|
|
}
|
|
_, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
|
|
if err == nil {
|
|
t.Fatal("expected conflict error")
|
|
}
|
|
if !strings.Contains(err.Error(), "command conflict") {
|
|
t.Errorf("error should mention conflict, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuild_DetectsCronConflict(t *testing.T) {
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", nil, []Cron{noopCron("daily")}),
|
|
"beta": factory("beta", nil, []Cron{noopCron("daily")}),
|
|
}
|
|
_, err := Build([]string{"alpha", "beta"}, factories, baseDeps())
|
|
if err == nil || !strings.Contains(err.Error(), "cron conflict") {
|
|
t.Errorf("expected cron conflict, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuild_RequiresKV(t *testing.T) {
|
|
_, err := Build(nil, map[string]Factory{}, Deps{})
|
|
if err == nil {
|
|
t.Error("expected error when Deps.KV is nil")
|
|
}
|
|
}
|
|
|
|
func TestBuild_ValidationErrorsMentionModule(t *testing.T) {
|
|
bad := Command{Name: "BAD-NAME", Visibility: VisibilityPublic, Description: "x", Handler: noopCmd("x").Handler}
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", []Command{bad}, nil),
|
|
}
|
|
_, err := Build([]string{"alpha"}, factories, baseDeps())
|
|
if err == nil || !strings.Contains(err.Error(), "alpha") {
|
|
t.Errorf("expected error mentioning module 'alpha', got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDispatchScheduled_RunsHandler(t *testing.T) {
|
|
called := false
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", nil, []Cron{{
|
|
Name: "tick",
|
|
Handler: func(_ context.Context, _ Deps) error {
|
|
called = true
|
|
return nil
|
|
},
|
|
}}),
|
|
}
|
|
reg, err := Build([]string{"alpha"}, factories, baseDeps())
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if err := DispatchScheduled(context.Background(), "tick", reg); err != nil {
|
|
t.Fatalf("DispatchScheduled: %v", err)
|
|
}
|
|
if !called {
|
|
t.Error("cron handler not invoked")
|
|
}
|
|
}
|
|
|
|
func TestDispatchScheduled_UnknownReturnsErrCronNotFound(t *testing.T) {
|
|
reg, err := Build(nil, map[string]Factory{}, baseDeps())
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
err = DispatchScheduled(context.Background(), "missing", reg)
|
|
if !errors.Is(err, ErrCronNotFound) {
|
|
t.Errorf("expected ErrCronNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) {
|
|
ctx := context.Background()
|
|
base := storage.NewMemoryKVStore()
|
|
|
|
factories := map[string]Factory{
|
|
"alpha": func(d Deps) Module {
|
|
return Module{Crons: []Cron{{
|
|
Name: "tick_a",
|
|
Handler: func(ctx context.Context, deps Deps) error {
|
|
return deps.KV.Put(ctx, "last", []byte("A"))
|
|
},
|
|
}}}
|
|
},
|
|
"beta": func(d Deps) Module {
|
|
return Module{Crons: []Cron{{
|
|
Name: "tick_b",
|
|
Handler: func(ctx context.Context, deps Deps) error {
|
|
return deps.KV.Put(ctx, "last", []byte("B"))
|
|
},
|
|
}}}
|
|
},
|
|
}
|
|
reg, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base})
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if err := DispatchScheduled(ctx, "tick_a", reg); err != nil {
|
|
t.Fatalf("tick_a: %v", err)
|
|
}
|
|
if err := DispatchScheduled(ctx, "tick_b", reg); err != nil {
|
|
t.Fatalf("tick_b: %v", err)
|
|
}
|
|
|
|
// Underlying base store should hold each module's prefixed key separately.
|
|
gotA, err := base.Get(ctx, "alpha:last")
|
|
if err != nil || string(gotA) != "A" {
|
|
t.Errorf("alpha:last = %q (err=%v), want A", gotA, err)
|
|
}
|
|
gotB, err := base.Get(ctx, "beta:last")
|
|
if err != nil || string(gotB) != "B" {
|
|
t.Errorf("beta:last = %q (err=%v), want B", gotB, err)
|
|
}
|
|
}
|
|
|
|
func TestBuild_RejectsInvalidModuleName(t *testing.T) {
|
|
for _, name := range []string{"BadName", "with-dash", "a:b", ""} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := Build([]string{name}, map[string]Factory{}, baseDeps())
|
|
if err == nil {
|
|
t.Errorf("name %q: expected error", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuild_RejectsDuplicateModuleInEnv(t *testing.T) {
|
|
factories := map[string]Factory{
|
|
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
|
}
|
|
_, err := Build([]string{"alpha", "alpha"}, factories, baseDeps())
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
|
t.Errorf("expected duplicate-module error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuild_PerModulePrefixedKV(t *testing.T) {
|
|
ctx := context.Background()
|
|
base := storage.NewMemoryKVStore()
|
|
|
|
// Each module writes a value to the same key; with per-module prefixing
|
|
// they must not collide.
|
|
captured := map[string]Deps{}
|
|
factories := map[string]Factory{
|
|
"alpha": func(d Deps) Module {
|
|
captured["alpha"] = d
|
|
return Module{Commands: []Command{noopCmd("a")}}
|
|
},
|
|
"beta": func(d Deps) Module {
|
|
captured["beta"] = d
|
|
return Module{Commands: []Command{noopCmd("b")}}
|
|
},
|
|
}
|
|
if _, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base}); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
|
|
if err := captured["alpha"].KV.Put(ctx, "score", []byte("1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := captured["beta"].KV.Put(ctx, "score", []byte("2")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := captured["alpha"].KV.Get(ctx, "score")
|
|
if string(got) != "1" {
|
|
t.Errorf("alpha.KV.score = %q, want 1", got)
|
|
}
|
|
got, _ = captured["beta"].KV.Get(ctx, "score")
|
|
if string(got) != "2" {
|
|
t.Errorf("beta.KV.score = %q, want 2", got)
|
|
}
|
|
}
|