Files
ratatoskr-go/internal/app/app_test.go
ki.sagidullin d41c0d172f feat(app): add semantic Version constant 0.1.0
Add exported constant app.Version in major.minor.patch format (0.1.0) with
documentation of the manual increment process (when to bump patch/minor/major).
Include the version in the /help command reply and add tests verifying the
semver format and the version presence in help text.
2026-08-17 15:58:14 +05:00

247 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// TestNew проверяет, что composition root собирается без ошибок.
// Использует :memory: (? или file-based с t.TempDir).
func TestNew(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "test.db")
// Настраиваем минимальные env для обязательных полей
t.Setenv("TG_TOKEN", "test:token")
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
// Загружаем без config-файла (дефолты + env)
a, err := New("", "dev", "")
if err != nil {
t.Fatalf("New() err = %v", err)
}
if a.Store == nil {
t.Fatal("Store не создан")
}
if a.Router == nil {
t.Fatal("Router не создан")
}
if a.CoreCtx == nil {
t.Fatal("Core не создан")
}
if a.Worker == nil {
t.Fatal("Worker не создан")
}
if a.tg == nil {
t.Fatal("Telegram-канал не создан")
}
t.Logf("app OK: db=%s", a.Config.Paths.DB)
}
// TestNew_MissingToken проверяет ошибку при пустом токене.
func TestNew_MissingToken(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "test.db")
t.Setenv("TG_TOKEN", "")
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
if err == nil {
t.Fatal("expected error for missing token")
}
t.Logf("got expected error: %v", err)
}
// TestNew_BadDB проверяет ошибку при пути к БД, который нельзя создать.
func TestNew_BadDB(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "nonexistent", "test.db") // родителя нет
t.Setenv("TG_TOKEN", "test:token")
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
if err == nil {
t.Fatal("expected error for invalid db path")
}
if !strings.Contains(err.Error(), "db open error") {
t.Errorf("expected 'db open error' in message, got %v", err)
}
}
// TestNew_CtxCancel проверяет, что Run завершается на отмене контекста.
func TestNew_RunCtxCancel(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "test-runcancel.db")
t.Setenv("TG_TOKEN", "test:token")
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
a, err := New("", "dev", "")
if err != nil {
t.Fatalf("New() err = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // сразу отменяем
err = a.Run(ctx)
if err != nil {
t.Fatalf("Run() expected nil, got %v", err)
}
t.Log("Run OK on cancelled context")
}
// TestMain — дёргает fatalf, нормально что os.Exit не тестируется.
// Просто тест, что main.go компилируется.
func TestMain(t *testing.T) {
// main() не вызываем — он бы os.Exit
_, err := os.Stat("../../cmd/ratatoskr/main.go")
if err != nil {
t.Fatalf("main.go not found: %v", err)
}
t.Log("main.go exists")
}
// TestNew_WorktreeCreated проверяет, что New создаёт каталог worktree (decider и
// воркер запускают opencode с cwd=worktree — каталог должен существовать заранее).
func TestNew_WorktreeCreated(t *testing.T) {
tmp := t.TempDir()
wt := filepath.Join(tmp, "nested", "worktrees")
configPath := filepath.Join(tmp, "config.yaml")
content := strings.Join([]string{
"telegram:",
" token: \"test:token\"",
" chat_id: \"12345\"",
"paths:",
" db: \"" + filepath.Join(tmp, "test.db") + "\"",
" worktree: \"" + wt + "\"",
"",
}, "\n")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
a, err := New(configPath, "dev", "")
if err != nil {
t.Fatalf("New() err = %v", err)
}
defer a.Store.Close()
st, err := os.Stat(wt)
if err != nil {
t.Fatalf("worktree должен существовать после New: %v", err)
}
if !st.IsDir() {
t.Fatalf("worktree не директория: %v", st.Mode())
}
if a.Worker.Worktree != wt {
t.Errorf("Worker.Worktree = %q, want %q", a.Worker.Worktree, wt)
}
t.Logf("worktree создан: %s", wt)
}
// TestNew_UpdateWiring проверяет: 1) update.base_url берётся из update-блока
// (не git.base_url); 2) приоритет токена: вшитый updateToken > update.token.
func TestNew_UpdateWiring(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "test.db")
configPath := filepath.Join(tmp, "config.yaml")
content := strings.Join([]string{
"telegram:",
" token: \"test:token\"",
" chat_id: \"12345\"",
"git:",
" base_url: \"http://local-git/\"",
" token: \"git-token\"",
"update:",
" enabled: true",
" base_url: \"https://hub.example.com\"",
" token: \"cfg-update-token\"",
"paths:",
" db: \"" + dbPath + "\"",
"", // пустая строка в конце
}, "\n")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
// 1) base_url из update-блока, а НЕ git
a, err := New(configPath, "dev", "")
if err != nil {
t.Fatalf("New() err = %v", err)
}
if a.Updater == nil {
t.Fatal("Updater не создан")
}
if want := "https://hub.example.com"; a.Updater.BaseURL != want {
t.Errorf("BaseURL = %q, want %q (должен быть update.base_url, не git.base_url)", a.Updater.BaseURL, want)
}
// владелец пакета — жёсткая константа
if a.Updater.Owner != packageOwner {
t.Errorf("Owner = %q, want %q (константа)", a.Updater.Owner, packageOwner)
}
// без вшитого токена берём update.token из конфига
if a.Updater.Token != "cfg-update-token" {
t.Errorf("Token = %q, want cfg-update-token (при пустом вшитом)", a.Updater.Token)
}
// 2) вшитый updateToken перекрывает конфиг
a2, err := New(configPath, "dev", "embedded-update-token")
if err != nil {
t.Fatalf("New() err = %v", err)
}
if a2.Updater.Token != "embedded-update-token" {
t.Errorf("Token = %q, want embedded-update-token (вшитый приоритетнее)", a2.Updater.Token)
}
}
// TestHelp_IsBinaryCommand проверяет, что /help распознаётся cmdName и, значит,
// в handleIncoming попадёт в бинарную ветку (не в машину состояний задач).
// Косвенно: /help не должен иметь аргумента-номера задачи (как /status N).
func TestHelp_IsBinaryCommand(t *testing.T) {
if got := cmdName("/help"); got != "/help" {
t.Errorf("cmdName(/help) = %q, want /help", got)
}
if hasArg("/help") {
t.Errorf("/help не должен требовать аргумент")
}
if got := cmdName("/help foo"); got != "/help" {
t.Errorf("cmdName(/help foo) = %q, want /help", got)
}
}
// TestHelpText_ContainsCommands проверяет, что справка упоминает все команды
// и номер версии приложения.
func TestHelpText_ContainsCommands(t *testing.T) {
text := helpTextFor(Version)
for _, cmd := range []string{
"/start", "/cancel", "/skip", "/retry", "/status", "/continue",
"/update", "/help",
} {
if !strings.Contains(text, cmd) {
t.Errorf("helpText не упоминает команду %s", cmd)
}
}
if !strings.Contains(text, "Ratatoskr") {
t.Errorf("helpText не содержит имени бота")
}
if !strings.Contains(text, Version) {
t.Errorf("helpText не содержит номера версии приложения %s", Version)
}
}
// TestVersion_Semver проверяет, что константа версии приложения объявлена
// в формате major.minor.patch (например 0.1.0).
func TestVersion_Semver(t *testing.T) {
re := regexp.MustCompile(`^\d+\.\d+\.\d+$`)
if !re.MatchString(Version) {
t.Errorf("Version = %q, ожидался формат major.minor.patch", Version)
}
}