Files
ratatoskr-go/internal/app/app_test.go
ki.sagidullin b5fb583c90
All checks were successful
CI / test (pull_request) Successful in 41s
CI / build-and-package (amd64, linux) (pull_request) Successful in 37s
CI / build-and-package (amd64, windows) (pull_request) Successful in 34s
test: починить тесты на Windows
- E2E (app): e2eFakeAPI переведён на v2 HTTP API opencode (/api/*) с
  определением агента по тексту промпта; Router получает processed-счётчик
  и WaitProcessed, e2eChannel.deliver ждёт асинхронную обработку — убирает
  гонку «запрос сразу после deliver» и коллатеральный 'database is closed'.
- app_test: одинарные YAML-кавычки для путей Windows (backslash-escape) +
  закрытие Store в TestNew/TestNew_RunCtxCancel/TestNew_UpdateWiring.
- config_test: абсолютный путь строится с корнем тома (C:\...) и одинарными
  кавычками YAML.
- opencode/server_test: fakeServeBin на Windows — .cmd с ping (#!/bin/sh
  не исполняется).
- worker_test: TestWorkerSemaphore поллит до целевого статуса вместо
  фиксированных sleep (git на Windows медленнее).
2026-08-19 10:47:55 +05:00

251 lines
8.1 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)
}
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)
}
}