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) } defer a.Store.Close() 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) } defer a.Store.Close() 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) } defer a.Store.Close() 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) } defer a2.Store.Close() 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) } }