- 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 медленнее).
251 lines
8.1 KiB
Go
251 lines
8.1 KiB
Go
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)
|
||
}
|
||
} |