package graph import ( "context" "sync" "sync/atomic" "testing" "time" "github.com/local/glpi-neural-brain/internal/model" ) func TestResearchTaskLifecycleSQLite(t *testing.T) { store, err := Open(t.TempDir()) if err != nil { t.Fatal(err) } defer store.Close() ctx := context.Background() request := model.ResearchTask{ DedupeKey: "zfs-restore", Topic: "ZFS Restore", Reason: "missing_validation", RequestedBy: "test", Priority: .91, Questions: []string{"Wie wird ein Restore validiert?"}, MaxAttempts: 3, } queued, created, err := store.EnqueueResearchTask(ctx, request, 24*time.Hour) if err != nil || !created { t.Fatalf("enqueue created=%t err=%v", created, err) } duplicate, created, err := store.EnqueueResearchTask(ctx, request, 24*time.Hour) if err != nil || created || duplicate.ID != queued.ID { t.Fatalf("dedupe created=%t original=%s duplicate=%s err=%v", created, queued.ID, duplicate.ID, err) } leased, ok, err := store.LeaseNextResearchTask(ctx, .65, 30*time.Minute) if err != nil || !ok || leased.ID != queued.ID || leased.Status != "reserved" || leased.Attempts != 1 { t.Fatalf("lease ok=%t task=%+v err=%v", ok, leased, err) } if err := store.MarkResearchTaskRunning(ctx, leased.ID); err != nil { t.Fatal(err) } leased.EvidenceCount = 3 leased.ArticleCreated = true leased.ArticleTitle = "ZFS Restore validieren" leased.Outcome = "article_created" if err := store.CompleteResearchTask(ctx, leased); err != nil { t.Fatal(err) } completed, err := store.GetResearchTask(ctx, leased.ID) if err != nil { t.Fatal(err) } if completed.Status != "completed" || completed.EvidenceCount != 3 || !completed.ArticleCreated || completed.CompletedAt.IsZero() { t.Fatalf("unexpected completed task: %+v", completed) } counts, err := store.ResearchTaskCounts(ctx) if err != nil || counts["completed"] != 1 { t.Fatalf("unexpected counts=%v err=%v", counts, err) } } func TestResearchTaskFailureDefersUntilMaxAttempts(t *testing.T) { store, err := Open(t.TempDir()) if err != nil { t.Fatal(err) } defer store.Close() ctx := context.Background() queued, _, err := store.EnqueueResearchTask(ctx, model.ResearchTask{Topic: "Btrfs Timeline", Priority: .8, MaxAttempts: 2}, 0) if err != nil { t.Fatal(err) } leased, ok, err := store.LeaseNextResearchTask(ctx, 0, 30*time.Minute) if err != nil || !ok { t.Fatalf("lease ok=%t err=%v", ok, err) } leased.LastError = "temporary" leased.Outcome = "failed" if err := store.FailResearchTask(ctx, leased, time.Millisecond); err != nil { t.Fatal(err) } first, err := store.GetResearchTask(ctx, queued.ID) if err != nil || first.Status != "deferred" { t.Fatalf("expected deferred task, got %+v err=%v", first, err) } time.Sleep(3 * time.Millisecond) leased, ok, err = store.LeaseNextResearchTask(ctx, 0, 30*time.Minute) if err != nil || !ok { t.Fatalf("second lease ok=%t err=%v", ok, err) } leased.LastError = "permanent" leased.Outcome = "failed" if err := store.FailResearchTask(ctx, leased, 0); err != nil { t.Fatal(err) } final, err := store.GetResearchTask(ctx, queued.ID) if err != nil || final.Status != "failed" || final.Attempts != 2 { t.Fatalf("expected failed task after max attempts, got %+v err=%v", final, err) } } func TestResearchTaskConcurrentEnqueueDeduplicates(t *testing.T) { store, err := Open(t.TempDir()) if err != nil { t.Fatal(err) } defer store.Close() ctx := context.Background() request := model.ResearchTask{DedupeKey: "same-search", Topic: "Same SearXNG task", Questions: []string{"same question"}, Priority: .8} var created atomic.Int64 var wg sync.WaitGroup errs := make(chan error, 16) for i := 0; i < 16; i++ { wg.Add(1) go func() { defer wg.Done() _, wasCreated, enqueueErr := store.EnqueueResearchTask(ctx, request, time.Hour) if enqueueErr != nil { errs <- enqueueErr return } if wasCreated { created.Add(1) } }() } wg.Wait() close(errs) for err := range errs { t.Fatal(err) } if got := created.Load(); got != 1 { t.Fatalf("expected exactly one created task, got %d", got) } tasks, err := store.ListResearchTasks(ctx, 100) if err != nil { t.Fatal(err) } if len(tasks) != 1 { t.Fatalf("expected exactly one queued task, got %d: %+v", len(tasks), tasks) } }