This commit is contained in:
Christoph K.
2026-03-20 07:08:00 +01:00
parent 8163f906cc
commit b1a576f61e
9 changed files with 851 additions and 17 deletions

View File

@@ -0,0 +1,43 @@
package agents
import "testing"
func TestHistoryMessage_Fields(t *testing.T) {
h := HistoryMessage{Role: "user", Content: "Hallo"}
if h.Role != "user" {
t.Errorf("Role: %q", h.Role)
}
if h.Content != "Hallo" {
t.Errorf("Content: %q", h.Content)
}
}
func TestRequest_HistoryAppend(t *testing.T) {
req := Request{
Action: ActionQuery,
Args: []string{"Frage"},
History: []HistoryMessage{
{Role: "user", Content: "Vorherige Frage"},
{Role: "assistant", Content: "Vorherige Antwort"},
},
}
if len(req.History) != 2 {
t.Errorf("History len: %d", len(req.History))
}
if req.History[0].Role != "user" {
t.Errorf("erstes Element: %q", req.History[0].Role)
}
}
func TestResponse_RawAnswer(t *testing.T) {
resp := Response{
Text: "**Formatierte** Antwort",
RawAnswer: "Formatierte Antwort",
}
if resp.RawAnswer == "" {
t.Error("RawAnswer sollte gesetzt sein")
}
if resp.Text == resp.RawAnswer {
t.Error("Text und RawAnswer sollten sich unterscheiden")
}
}

View File

@@ -0,0 +1,236 @@
package task
import (
"os"
"strings"
"testing"
"time"
"my-brain-importer/internal/agents"
)
func newTestAgent(t *testing.T) (*Agent, func()) {
t.Helper()
f, err := os.CreateTemp("", "tasks_agent_*.json")
if err != nil {
t.Fatalf("temp-Datei: %v", err)
}
name := f.Name()
f.Close()
os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück
a := &Agent{store: &Store{path: name}}
return a, func() { os.Remove(name) }
}
// ── parseAddArgs ─────────────────────────────────────────────────────────────
func TestParseAddArgs_TextOnly(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Arzttermin", "buchen"})
if text != "Arzttermin buchen" {
t.Errorf("text: %q", text)
}
if prio != "" {
t.Errorf("prio sollte leer sein: %q", prio)
}
if due != nil {
t.Error("due sollte nil sein")
}
}
func TestParseAddArgs_AllFlags(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Zahnarzt", "--due", "2026-12-01", "--priority", "hoch"})
if text != "Zahnarzt" {
t.Errorf("text: %q", text)
}
if prio != "hoch" {
t.Errorf("prio: %q", prio)
}
if due == nil {
t.Fatal("due sollte gesetzt sein")
}
if due.Format("2006-01-02") != "2026-12-01" {
t.Errorf("due: %v", due)
}
}
func TestParseAddArgs_ShortFlags(t *testing.T) {
text, prio, due := parseAddArgs([]string{"Meeting", "-p", "mittel", "-d", "2026-06-15"})
if text != "Meeting" {
t.Errorf("text: %q", text)
}
if prio != "mittel" {
t.Errorf("prio: %q", prio)
}
if due == nil || due.Format("2006-01-02") != "2026-06-15" {
t.Errorf("due: %v", due)
}
}
func TestParseAddArgs_InvalidDate(t *testing.T) {
_, _, due := parseAddArgs([]string{"Task", "--due", "kein-datum"})
if due != nil {
t.Error("ungültiges Datum sollte nil ergeben")
}
}
// ── Agent.Handle ─────────────────────────────────────────────────────────────
func TestAgent_Add(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Mein Task"}})
if resp.Error != nil {
t.Fatalf("Add: %v", resp.Error)
}
if !strings.Contains(resp.Text, "Mein Task") {
t.Errorf("Antwort enthält keinen Task-Text: %q", resp.Text)
}
}
func TestAgent_Add_WithDueAndPriority(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{
Action: agents.ActionAdd,
Args: []string{"Frist", "--due", "2026-12-31", "--priority", "hoch"},
})
if resp.Error != nil {
t.Fatalf("Add: %v", resp.Error)
}
if !strings.Contains(resp.Text, "Frist") {
t.Errorf("Task-Text fehlt: %q", resp.Text)
}
if !strings.Contains(resp.Text, "hoch") {
t.Errorf("Priorität fehlt: %q", resp.Text)
}
if !strings.Contains(resp.Text, "31.12.2026") {
t.Errorf("Datum fehlt: %q", resp.Text)
}
}
func TestAgent_Add_NoArgs(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{}})
if !strings.Contains(resp.Text, "❌") {
t.Errorf("erwartet Fehlermeldung, got: %q", resp.Text)
}
}
func TestAgent_List_Empty(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "Keine Tasks") {
t.Errorf("erwartet 'Keine Tasks': %q", resp.Text)
}
}
func TestAgent_List_ShowsDueDate(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
due := time.Now().Add(48 * time.Hour) // übermorgen
a.store.Add("Übermorgen", "", &due)
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "📅") {
t.Errorf("Datum-Icon fehlt in Liste: %q", resp.Text)
}
}
func TestAgent_List_ShowsOverdue(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
past := time.Now().Add(-48 * time.Hour) // vorgestern
a.store.Add("Überfällig", "", &past)
resp := a.Handle(agents.Request{Action: agents.ActionList})
if !strings.Contains(resp.Text, "ÜBERFÄLLIG") {
t.Errorf("ÜBERFÄLLIG fehlt: %q", resp.Text)
}
}
func TestAgent_Done(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
addResp := a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Erledigen"}})
// Short-ID aus Antwort extrahieren
tasks, _ := a.store.Load()
if len(tasks) == 0 {
t.Fatal("kein Task angelegt")
}
id := tasks[0].ID
shortID := id[len(id)-6:]
_ = addResp
resp := a.Handle(agents.Request{Action: agents.ActionDone, Args: []string{shortID}})
if resp.Error != nil {
t.Fatalf("Done: %v", resp.Error)
}
if !strings.Contains(resp.Text, "✅") {
t.Errorf("erwartet ✅: %q", resp.Text)
}
}
func TestAgent_Delete(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
a.Handle(agents.Request{Action: agents.ActionAdd, Args: []string{"Löschen"}})
tasks, _ := a.store.Load()
id := tasks[0].ID
shortID := id[len(id)-6:]
resp := a.Handle(agents.Request{Action: agents.ActionDelete, Args: []string{shortID}})
if resp.Error != nil {
t.Fatalf("Delete: %v", resp.Error)
}
if !strings.Contains(resp.Text, "🗑️") {
t.Errorf("erwartet 🗑️: %q", resp.Text)
}
tasks, _ = a.store.Load()
if len(tasks) != 0 {
t.Errorf("Task sollte gelöscht sein, noch %d vorhanden", len(tasks))
}
}
func TestAgent_UnknownAction(t *testing.T) {
a, cleanup := newTestAgent(t)
defer cleanup()
resp := a.Handle(agents.Request{Action: "unknown"})
if !strings.Contains(resp.Text, "❌") {
t.Errorf("erwartet Fehlermeldung: %q", resp.Text)
}
}
// ── resolveID ────────────────────────────────────────────────────────────────
func TestResolveID_FullMatch(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "123456789"); got != "123456789" {
t.Errorf("got %q", got)
}
}
func TestResolveID_ShortMatch(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "456789"); got != "123456789" {
t.Errorf("got %q", got)
}
}
func TestResolveID_NotFound(t *testing.T) {
tasks := []Task{{ID: "123456789"}}
if got := resolveID(tasks, "000000"); got != "" {
t.Errorf("erwartet leer, got %q", got)
}
}

View File

@@ -0,0 +1,169 @@
package task
import (
"os"
"testing"
"time"
)
func newTestStore(t *testing.T) (*Store, func()) {
t.Helper()
f, err := os.CreateTemp("", "tasks_test_*.json")
if err != nil {
t.Fatalf("temp-Datei erstellen: %v", err)
}
name := f.Name()
f.Close()
os.Remove(name) // Datei entfernen → loadLocked gibt leeres Slice zurück
s := &Store{path: name}
return s, func() { os.Remove(name) }
}
func TestStore_AddAndLoad(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
task, err := s.Add("Test Task", "hoch", nil)
if err != nil {
t.Fatalf("Add: %v", err)
}
if task.Text != "Test Task" {
t.Errorf("Text: got %q, want %q", task.Text, "Test Task")
}
if task.Priority != "hoch" {
t.Errorf("Priority: got %q, want %q", task.Priority, "hoch")
}
if task.Done {
t.Error("neuer Task sollte nicht Done sein")
}
tasks, err := s.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("len: got %d, want 1", len(tasks))
}
}
func TestStore_AddWithDueDate(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
task, err := s.Add("Frist-Task", "mittel", &due)
if err != nil {
t.Fatalf("Add: %v", err)
}
if task.DueDate == nil {
t.Fatal("DueDate sollte gesetzt sein")
}
if !task.DueDate.Equal(due) {
t.Errorf("DueDate: got %v, want %v", task.DueDate, due)
}
}
func TestStore_MarkDone(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
task, _ := s.Add("Erledigbarer Task", "", nil)
if err := s.MarkDone(task.ID); err != nil {
t.Fatalf("MarkDone: %v", err)
}
tasks, _ := s.Load()
if !tasks[0].Done {
t.Error("Task sollte Done=true sein")
}
if tasks[0].DoneAt == nil {
t.Error("DoneAt sollte gesetzt sein")
}
}
func TestStore_MarkDone_NotFound(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
err := s.MarkDone("nichtexistent")
if err == nil {
t.Error("erwartet Fehler für unbekannte ID")
}
}
func TestStore_Delete(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
t1, _ := s.Add("Task 1", "", nil)
s.Add("Task 2", "", nil)
if err := s.Delete(t1.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
tasks, _ := s.Load()
if len(tasks) != 1 {
t.Fatalf("nach Delete: got %d, want 1", len(tasks))
}
if tasks[0].Text != "Task 2" {
t.Errorf("falscher Task verblieben: %q", tasks[0].Text)
}
}
func TestStore_Delete_NotFound(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
err := s.Delete("nichtexistent")
if err == nil {
t.Error("erwartet Fehler für unbekannte ID")
}
}
func TestStore_OpenTasks(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
t1, _ := s.Add("Offen", "", nil)
s.Add("Auch offen", "", nil)
s.MarkDone(t1.ID)
open, err := s.OpenTasks()
if err != nil {
t.Fatalf("OpenTasks: %v", err)
}
if len(open) != 1 {
t.Fatalf("OpenTasks: got %d, want 1", len(open))
}
if open[0].Text != "Auch offen" {
t.Errorf("falscher offener Task: %q", open[0].Text)
}
}
func TestStore_EmptyFile(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
tasks, err := s.Load()
if err != nil {
t.Fatalf("Load auf leerer Datei: %v", err)
}
if len(tasks) != 0 {
t.Errorf("erwartet leer, got %d", len(tasks))
}
}
func TestStore_Idempotent_MultipleAdds(t *testing.T) {
s, cleanup := newTestStore(t)
defer cleanup()
s.Add("A", "", nil)
s.Add("B", "niedrig", nil)
s.Add("C", "hoch", nil)
tasks, _ := s.Load()
if len(tasks) != 3 {
t.Fatalf("erwartet 3 Tasks, got %d", len(tasks))
}
}

View File

@@ -0,0 +1,72 @@
package email
import (
"strings"
"testing"
"time"
)
var testMessages = []Message{
{Subject: "Rechnung März", From: "buchhaltung@firma.de", Date: "2026-03-01 09:00"},
{Subject: "Meeting Einladung", From: "chef@firma.de", Date: "2026-03-02 10:30"},
{Subject: "Newsletter", From: "news@shop.de", Date: "2026-03-03 08:00"},
}
func TestFormatEmailList_ContainsFields(t *testing.T) {
result := formatEmailList(testMessages)
checks := []string{"Rechnung März", "buchhaltung@firma.de", "Meeting Einladung", "Newsletter"}
for _, s := range checks {
if !strings.Contains(result, s) {
t.Errorf("formatEmailList: fehlt %q in:\n%s", s, result)
}
}
}
func TestFormatEmailList_NumberedLines(t *testing.T) {
result := formatEmailList(testMessages)
if !strings.Contains(result, "[1]") {
t.Error("fehlt [1] in Ausgabe")
}
if !strings.Contains(result, "[3]") {
t.Error("fehlt [3] in Ausgabe")
}
}
func TestFormatEmailList_Empty(t *testing.T) {
result := formatEmailList([]Message{})
if result != "" {
t.Errorf("leere Liste sollte leeren String ergeben, got: %q", result)
}
}
func TestFallbackList_ContainsWarning(t *testing.T) {
result := fallbackList(testMessages)
if !strings.Contains(result, "LLM nicht verfügbar") {
t.Errorf("Fallback-Hinweis fehlt: %q", result)
}
}
func TestFallbackList_ContainsAllMessages(t *testing.T) {
result := fallbackList(testMessages)
for _, m := range testMessages {
if !strings.Contains(result, m.Subject) {
t.Errorf("Betreff fehlt: %q", m.Subject)
}
}
}
func TestParseMessages_Empty(t *testing.T) {
result := parseMessages(nil)
if len(result) != 0 {
t.Errorf("erwartet leer, got %d", len(result))
}
}
func TestMessage_DateFormat(t *testing.T) {
// Datum muss im Format "2006-01-02 15:04" formatiert werden
m := testMessages[0]
if _, err := time.Parse("2006-01-02 15:04", m.Date); err != nil {
t.Errorf("Datumsformat ungültig: %v", err)
}
}

120
internal/diag/diag.go Normal file
View File

@@ -0,0 +1,120 @@
// diag Start-Diagnose: prüft Erreichbarkeit aller externen Dienste
package diag
import (
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"my-brain-importer/internal/config"
)
// Result ist das Ergebnis einer einzelnen Prüfung.
type Result struct {
Name string
OK bool
Message string
}
// RunAll führt alle Verbindungs-Checks durch und gibt eine Zusammenfassung zurück.
// warnings = Dienste die konfiguriert aber nicht erreichbar sind.
func RunAll() (results []Result, allOK bool) {
allOK = true
cfg := config.Cfg
check := func(name string, ok bool, msg string) {
results = append(results, Result{Name: name, OK: ok, Message: msg})
if !ok {
allOK = false
}
}
// Qdrant
qdrantAddr := fmt.Sprintf("%s:%s", cfg.Qdrant.Host, cfg.Qdrant.Port)
ok, msg := tcpCheck(qdrantAddr)
check("Qdrant ("+qdrantAddr+")", ok, msg)
// LocalAI Chat
if cfg.Chat.URL != "" {
ok, msg = httpCheck(cfg.Chat.URL)
check("LocalAI Chat ("+cfg.Chat.URL+")", ok, msg)
}
// LocalAI Embedding (nur prüfen wenn andere URL als Chat)
if cfg.Embedding.URL != "" && cfg.Embedding.URL != cfg.Chat.URL {
ok, msg = httpCheck(cfg.Embedding.URL)
check("LocalAI Embedding ("+cfg.Embedding.URL+")", ok, msg)
}
// IMAP
if cfg.Email.Host != "" {
imapAddr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
ok, msg = tcpCheck(imapAddr)
check("IMAP ("+imapAddr+")", ok, msg)
}
return results, allOK
}
// Log gibt die Ergebnisse über slog aus.
func Log(results []Result) {
for _, r := range results {
if r.OK {
slog.Info("Dienst-Check", "dienst", r.Name, "status", "OK", "info", r.Message)
} else {
slog.Warn("Dienst-Check", "dienst", r.Name, "status", "FEHLER", "info", r.Message)
}
}
}
// Format gibt eine menschenlesbare Zusammenfassung zurück (für Discord/stdout).
func Format(results []Result, allOK bool) string {
var sb strings.Builder
sb.WriteString("🔍 **Start-Diagnose:**\n")
for _, r := range results {
icon := "✅"
if !r.OK {
icon = "❌"
}
fmt.Fprintf(&sb, "%s %s %s\n", icon, r.Name, r.Message)
}
if allOK {
sb.WriteString("\n✅ Alle Dienste erreichbar.")
} else {
sb.WriteString("\n⚠ Einige Dienste sind nicht erreichbar — Bot läuft, aber Funktionen könnten fehlen.")
}
return sb.String()
}
func tcpCheck(addr string) (bool, string) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false, "nicht erreichbar: " + err.Error()
}
conn.Close()
return true, "TCP OK"
}
func httpCheck(baseURL string) (bool, string) {
// Normalisiere URL: entferne trailing /v1 etc., hänge /v1/models an
url := strings.TrimRight(baseURL, "/")
if !strings.HasSuffix(url, "/models") {
// Gehe zur Basis-URL zurück und frage /v1/models
if idx := strings.LastIndex(url, "/v1"); idx >= 0 {
url = url[:idx]
}
url += "/v1/models"
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return false, "nicht erreichbar: " + err.Error()
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK,
fmt.Sprintf("HTTP %d", resp.StatusCode)
}