package server import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/dal/kaos/internal/config" "github.com/dal/kaos/internal/supervisor" ) const testTask1 = `# T01: Prvi task **Agent:** coder **Model:** Sonnet **Zavisi od:** — --- ## Opis Opis prvog taska. --- ` const testTask2 = `# T08: HTTP server **Agent:** coder **Model:** Sonnet **Zavisi od:** T07 --- ## Opis Implementacija HTTP servera. --- ` func setupTestServer(t *testing.T) *Server { t.Helper() dir := t.TempDir() tasksDir := filepath.Join(dir, "TASKS") for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} { os.MkdirAll(filepath.Join(tasksDir, folder), 0755) } os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) // Docs: create markdown files in project root os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# CLAUDE.md\n\nGlavni fajl.\n\n| Kolona | Opis |\n|--------|------|\n| A | B |\n"), 0644) os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644) os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755) os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644) os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755) os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644) os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644) cfg := &config.Config{ TasksDir: tasksDir, ProjectPath: dir, Port: "0", } return New(cfg) } func TestAPIGetTasks(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var tasks []taskResponse if err := json.Unmarshal(w.Body.Bytes(), &tasks); err != nil { t.Fatalf("invalid JSON: %v", err) } if len(tasks) != 2 { t.Fatalf("expected 2 tasks, got %d", len(tasks)) } // Check that tasks have correct statuses statuses := map[string]string{} for _, task := range tasks { statuses[task.ID] = task.Status } if statuses["T01"] != "done" { t.Errorf("expected T01 status done, got %s", statuses["T01"]) } if statuses["T08"] != "backlog" { t.Errorf("expected T08 status backlog, got %s", statuses["T08"]) } } func TestAPIGetTask(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/task/T01", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var detail taskDetailResponse if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil { t.Fatalf("invalid JSON: %v", err) } if detail.ID != "T01" { t.Errorf("expected T01, got %s", detail.ID) } if detail.Content == "" { t.Error("expected non-empty content") } } func TestAPIGetTask_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/task/T99", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestAPIMoveTask(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify file was moved if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "ready", "T08.md")); err != nil { t.Error("expected T08.md in ready/") } if _, err := os.Stat(filepath.Join(srv.Config.TasksDir, "backlog", "T08.md")); !os.IsNotExist(err) { t.Error("expected T08.md removed from backlog/") } } func TestAPIMoveTask_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/api/task/T99/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestAPIMoveTask_InvalidFolder(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=invalid", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestDashboardHTML(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "KAOS Dashboard") { t.Error("expected 'KAOS Dashboard' in HTML") } if !containsStr(body, "T01") { t.Error("expected T01 in HTML") } if !containsStr(body, "T08") { t.Error("expected T08 in HTML") } if !containsStr(body, "BACKLOG") { t.Error("expected BACKLOG column in HTML") } if !containsStr(body, "DONE") { t.Error("expected DONE column in HTML") } } func TestTaskDetailHTML(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/task/T01", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "T01") { t.Error("expected T01 in detail HTML") } if !containsStr(body, "Prvi task") { t.Error("expected task title in detail HTML") } } func TestTaskDetailHTML_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/task/T99", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestHTMLMoveTask(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Should return updated dashboard HTML body := w.Body.String() if !containsStr(body, "KAOS Dashboard") { t.Error("expected dashboard HTML after move") } } func TestDashboardHTML_HasAllColumns(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() for _, col := range []string{"BACKLOG", "READY", "ACTIVE", "REVIEW", "DONE"} { if !containsStr(body, col) { t.Errorf("expected %s column in dashboard", col) } } } func TestDashboardHTML_HasHTMXAttributes(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "hx-get") { t.Error("expected hx-get attributes in HTML") } if !containsStr(body, "hx-target") { t.Error("expected hx-target attributes in HTML") } } func TestDashboardHTML_TasksInCorrectColumns(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // T01 should be in done column, T08 in backlog if !containsStr(body, `id="col-done"`) { t.Error("expected col-done in HTML") } if !containsStr(body, `id="col-backlog"`) { t.Error("expected col-backlog in HTML") } } func TestReport_Exists(t *testing.T) { srv := setupTestServer(t) // Create a report reportsDir := filepath.Join(srv.Config.TasksDir, "reports") os.MkdirAll(reportsDir, 0755) os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 Report\nSve ok."), 0644) req := httptest.NewRequest(http.MethodGet, "/report/T01", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !containsStr(w.Body.String(), "T01 Report") { t.Error("expected report content") } } func TestReport_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/report/T99", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestTaskDetail_HasMoveButtons(t *testing.T) { srv := setupTestServer(t) // T08 is in backlog, should have "Odobri" button req := httptest.NewRequest(http.MethodGet, "/task/T08", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "Odobri") { t.Error("expected 'Odobri' button for backlog task") } } func TestAPIMoveTask_ForbiddenToActive(t *testing.T) { srv := setupTestServer(t) // Put T08 in ready first os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) // Try to move ready → active (agent-only) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=active", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for ready→active, got %d: %s", w.Code, w.Body.String()) } } func TestAPIMoveTask_ForbiddenActiveToReview(t *testing.T) { srv := setupTestServer(t) // Put T08 in active os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "active", "T08.md"), ) // Try to move active → review (agent-only) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=review", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for active→review, got %d: %s", w.Code, w.Body.String()) } } func TestAPIMoveTask_AllowedBacklogToReady(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for backlog→ready, got %d: %s", w.Code, w.Body.String()) } } func TestAPIMoveTask_AllowedReviewToDone(t *testing.T) { srv := setupTestServer(t) // Put T08 in review os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "review", "T08.md"), ) req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=done", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for review→done, got %d: %s", w.Code, w.Body.String()) } } func TestDashboardHTML_HasSortableScript(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "sortable.min.js") { t.Error("expected sortable.min.js script tag") } if !containsStr(body, "initSortable") { t.Error("expected initSortable function") } } func TestDashboardHTML_HasDataFolderAttributes(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, `data-folder="backlog"`) { t.Error("expected data-folder attribute on column-tasks") } if !containsStr(body, `data-folder="ready"`) { t.Error("expected data-folder=ready attribute") } } func TestIsMoveAllowed(t *testing.T) { tests := []struct { from, to string allowed bool }{ {"backlog", "ready", true}, {"ready", "backlog", true}, {"review", "done", true}, {"review", "ready", true}, {"done", "review", true}, {"ready", "active", false}, {"active", "review", false}, {"backlog", "done", false}, {"backlog", "active", false}, {"done", "backlog", false}, {"ready", "ready", false}, } for _, tt := range tests { got := isMoveAllowed(tt.from, tt.to) if got != tt.allowed { t.Errorf("isMoveAllowed(%s, %s) = %v, want %v", tt.from, tt.to, got, tt.allowed) } } } func TestNoCacheHeaders(t *testing.T) { srv := setupTestServer(t) // Dynamic route should have no-cache headers req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) cc := w.Header().Get("Cache-Control") if !containsStr(cc, "no-store") { t.Errorf("expected Cache-Control no-store on dashboard, got %q", cc) } // API route should also have no-cache headers req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) w2 := httptest.NewRecorder() srv.Router.ServeHTTP(w2, req2) cc2 := w2.Header().Get("Cache-Control") if !containsStr(cc2, "no-store") { t.Errorf("expected Cache-Control no-store on API, got %q", cc2) } } func TestDashboardReflectsDiskChanges(t *testing.T) { srv := setupTestServer(t) // Initial state: T08 in backlog req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) var tasks1 []taskResponse json.Unmarshal(w.Body.Bytes(), &tasks1) found := false for _, task := range tasks1 { if task.ID == "T08" && task.Status == "backlog" { found = true } } if !found { t.Fatal("expected T08 in backlog initially") } // Move file on disk (simulating external change) os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) // Second request should reflect the change without server restart req2 := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) w2 := httptest.NewRecorder() srv.Router.ServeHTTP(w2, req2) var tasks2 []taskResponse json.Unmarshal(w2.Body.Bytes(), &tasks2) found = false for _, task := range tasks2 { if task.ID == "T08" && task.Status == "ready" { found = true } } if !found { t.Fatal("expected T08 in ready after disk move — server did not read fresh state") } } func TestDocsList(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() if !containsStr(body, "CLAUDE.md") { t.Error("expected CLAUDE.md in docs list") } if !containsStr(body, "README.md") { t.Error("expected README.md in docs list") } if !containsStr(body, "agents/coder/CLAUDE.md") { t.Error("expected agents/coder/CLAUDE.md in docs list") } } func TestDocsView_CLAUDE(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() if !containsStr(body, "Glavni fajl") { t.Error("expected rendered markdown content") } // Should have table rendered as HTML if !containsStr(body, "
| ") { t.Error("expected HTML table from markdown") } } func TestDocsView_NestedFile(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() if !containsStr(body, "Coder Agent") { t.Error("expected nested file content") } // Breadcrumbs if !containsStr(body, "agents") { t.Error("expected breadcrumb for agents") } } func TestDocsView_PathTraversal(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/../../etc/passwd", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for path traversal, got %d", w.Code) } } func TestDocsView_NonMarkdown(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/main.go", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-.md file, got %d", w.Code) } } func TestDocsView_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/nonexistent.md", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestDocsView_HasBreadcrumbs(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/agents/coder/CLAUDE.md", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "Dokumenti") { t.Error("expected 'Dokumenti' in breadcrumbs") } if !containsStr(body, "coder") { t.Error("expected 'coder' in breadcrumbs") } } func TestSearch_FindsTask(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "T01") { t.Error("expected T01 in search results for 'Prvi'") } } func TestSearch_FindsTaskByID(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "T08") { t.Error("expected T08 in search results") } } func TestSearch_FindsDocument(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "checker/CLAUDE.md") { t.Error("expected checker CLAUDE.md in search results") } } func TestSearch_FindsReport(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "T01-report.md") { t.Error("expected T01 report in search results") } } func TestSearch_EmptyQuery(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if w.Body.Len() != 0 { t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len()) } } func TestSearch_NoResults(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "Nema rezultata") { t.Error("expected 'Nema rezultata' message") } } func TestSearch_CaseInsensitive(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "coder") { t.Error("expected case-insensitive match for 'CODER'") } } func TestSearch_HasSnippet(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "kodiranja") { t.Error("expected snippet with 'kodiranja' text") } } func TestRunTask_Ready(t *testing.T) { srv := setupTestServer(t) // Move T08 to ready first os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "started" { t.Errorf("expected status started, got %v", resp["status"]) } if resp["session"] == nil { t.Error("expected session number in response") } } func TestRunTask_BacklogWithDeps(t *testing.T) { srv := setupTestServer(t) // T08 depends on T07, T01 is in done // T08 depends on T07 which is NOT in done → should fail req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for unmet deps, got %d: %s", w.Code, w.Body.String()) } } func TestRunTask_AlreadyDone(t *testing.T) { srv := setupTestServer(t) // T01 is in done req := httptest.NewRequest(http.MethodPost, "/task/T01/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for done task, got %d", w.Code) } } func TestRunTask_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/task/T99/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestRunTask_BothSessionsBusy(t *testing.T) { srv := setupTestServer(t) // Move T08 to ready os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) // Occupy both sessions srv.console.sessions[0].mu.Lock() srv.console.sessions[0].status = "running" srv.console.sessions[0].mu.Unlock() srv.console.sessions[1].mu.Lock() srv.console.sessions[1].status = "running" srv.console.sessions[1].mu.Unlock() req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Fatalf("expected 409 when both sessions busy, got %d: %s", w.Code, w.Body.String()) } // Clean up srv.console.sessions[0].mu.Lock() srv.console.sessions[0].status = "idle" srv.console.sessions[0].mu.Unlock() srv.console.sessions[1].mu.Lock() srv.console.sessions[1].status = "idle" srv.console.sessions[1].mu.Unlock() } func TestDashboardHTML_HasRunButton(t *testing.T) { srv := setupTestServer(t) // Move T08 to ready to test run button os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "Pusti") { t.Error("expected 'Pusti' button for ready task") } if !containsStr(body, "btn-run") { t.Error("expected btn-run class") } } func TestResolveTaskAction_Blocked(t *testing.T) { doneSet := map[string]bool{"T01": true} task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} if got := resolveTaskAction(task, doneSet); got != "blocked" { t.Errorf("expected blocked, got %s", got) } } func TestResolveTaskAction_Review(t *testing.T) { doneSet := map[string]bool{"T07": true} task := supervisor.Task{ID: "T08", Status: "backlog", DependsOn: []string{"T07"}} if got := resolveTaskAction(task, doneSet); got != "review" { t.Errorf("expected review, got %s", got) } } func TestResolveTaskAction_Run(t *testing.T) { task := supervisor.Task{ID: "T08", Status: "ready"} if got := resolveTaskAction(task, nil); got != "run" { t.Errorf("expected run, got %s", got) } } func TestResolveTaskAction_Running(t *testing.T) { task := supervisor.Task{ID: "T08", Status: "active"} if got := resolveTaskAction(task, nil); got != "running" { t.Errorf("expected running, got %s", got) } } func TestResolveTaskAction_Approve(t *testing.T) { task := supervisor.Task{ID: "T08", Status: "review"} if got := resolveTaskAction(task, nil); got != "approve" { t.Errorf("expected approve, got %s", got) } } func TestResolveTaskAction_Done(t *testing.T) { task := supervisor.Task{ID: "T01", Status: "done"} if got := resolveTaskAction(task, nil); got != "done" { t.Errorf("expected done, got %s", got) } } func TestDashboardHTML_BlockedButton(t *testing.T) { srv := setupTestServer(t) // T08 in backlog with dep T07 not in done → blocked req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "Blokiran") { t.Error("expected 'Blokiran' for backlog task with unmet deps") } if !containsStr(body, "btn-blocked") { t.Error("expected btn-blocked class") } } func TestDashboardHTML_DoneReportButton(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // T01 is in done → should have "Izvestaj" button if !containsStr(body, "btn-report") { t.Error("expected btn-report for done task") } } func TestSSE_EventsEndpoint(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/events", nil) w := httptest.NewRecorder() // Use a context with cancel to stop the SSE handler ctx, cancel := req.Context(), func() {} _ = ctx _ = cancel // Just check the handler starts without error and sets correct headers go func() { srv.Router.ServeHTTP(w, req) }() time.Sleep(100 * time.Millisecond) if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { t.Errorf("expected Content-Type text/event-stream, got %s", ct) } } func TestHashTaskState(t *testing.T) { tasks1 := []supervisor.Task{ {ID: "T01", Status: "done"}, {ID: "T02", Status: "backlog"}, } tasks2 := []supervisor.Task{ {ID: "T01", Status: "done"}, {ID: "T02", Status: "ready"}, // changed } tasks3 := []supervisor.Task{ {ID: "T02", Status: "backlog"}, {ID: "T01", Status: "done"}, // same as tasks1 but different order } h1 := hashTaskState(tasks1) h2 := hashTaskState(tasks2) h3 := hashTaskState(tasks3) if h1 == h2 { t.Error("hash should differ when task status changes") } if h1 != h3 { t.Error("hash should be same regardless of task order") } } func TestSSE_BroadcastOnChange(t *testing.T) { srv := setupTestServer(t) // Subscribe a client ch := srv.events.subscribe() defer srv.events.unsubscribe(ch) // Trigger a check — first call sets the baseline hash and broadcasts srv.events.checkAndBroadcast(func() string { return "board-html" }) // Drain initial broadcast select { case <-ch: case <-time.After(100 * time.Millisecond): } // Move a task to change state os.Rename( filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"), filepath.Join(srv.Config.TasksDir, "ready", "T08.md"), ) // Trigger another check — state changed, should broadcast srv.events.checkAndBroadcast(func() string { return "updated-board" }) select { case data := <-ch: if data != "updated-board" { t.Errorf("expected 'updated-board', got %s", data) } case <-time.After(time.Second): t.Error("expected broadcast after state change, got nothing") } } func TestSSE_NoBroadcastWithoutChange(t *testing.T) { srv := setupTestServer(t) ch := srv.events.subscribe() defer srv.events.unsubscribe(ch) // Two checks without changes — second should not broadcast srv.events.checkAndBroadcast(func() string { return "board" }) // Drain the first broadcast (initial hash set) select { case <-ch: case <-time.After(100 * time.Millisecond): } srv.events.checkAndBroadcast(func() string { return "board" }) select { case <-ch: t.Error("should not broadcast when state hasn't changed") case <-time.After(100 * time.Millisecond): // Good — no broadcast } } func TestConsolePage(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "Sesija 1") { t.Error("expected 'Sesija 1' in console page") } if !containsStr(body, "Sesija 2") { t.Error("expected 'Sesija 2' in console page") } } func TestConsoleSessions(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var statuses []sessionStatus if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil { t.Fatalf("invalid JSON: %v", err) } if len(statuses) != 2 { t.Fatalf("expected 2 sessions, got %d", len(statuses)) } if statuses[0].Status != "idle" || statuses[1].Status != "idle" { t.Error("expected both sessions idle") } } func TestConsoleExec_InvalidSession(t *testing.T) { srv := setupTestServer(t) body := `{"cmd":"status","session":3}` req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid session, got %d", w.Code) } } func TestConsoleExec_ValidRequest(t *testing.T) { srv := setupTestServer(t) body := `{"cmd":"echo test","session":1}` req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp execResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("invalid JSON: %v", err) } if resp.ExecID == "" { t.Error("expected non-empty exec ID") } if resp.Session != 1 { t.Errorf("expected session 1, got %d", resp.Session) } } func TestConsoleKill_IdleSession(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } } func TestConsoleHistory_Empty(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var history []historyEntry if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil { t.Fatalf("invalid JSON: %v", err) } if len(history) != 0 { t.Errorf("expected empty history, got %d entries", len(history)) } } func TestConsoleHistory_AfterExec(t *testing.T) { srv := setupTestServer(t) // Execute a command first body := `{"cmd":"test command","session":2}` req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) // Check history req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil) w2 := httptest.NewRecorder() srv.Router.ServeHTTP(w2, req2) var history []historyEntry json.Unmarshal(w2.Body.Bytes(), &history) if len(history) != 1 { t.Fatalf("expected 1 history entry, got %d", len(history)) } if history[0].Command != "test command" { t.Errorf("expected 'test command', got %s", history[0].Command) } } func TestDocsView_HasSidebarLayout(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "docs-layout") { t.Error("expected docs-layout class for grid layout") } if !containsStr(body, "docs-sidebar") { t.Error("expected docs-sidebar class") } if !containsStr(body, "docs-main") { t.Error("expected docs-main class") } // Sidebar should list files if !containsStr(body, "README.md") { t.Error("expected file list in sidebar") } } func TestDocsView_HTMXReturnsFragment(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs/CLAUDE.md", nil) req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // Should NOT have full page HTML if containsStr(body, "") { t.Error("HTMX request should return fragment, not full page") } // Should have breadcrumbs and content if !containsStr(body, "Dokumenti") { t.Error("expected breadcrumbs in fragment") } if !containsStr(body, "Glavni fajl") { t.Error("expected rendered content in fragment") } } func TestDocsList_HasSidebarLayout(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "docs-layout") { t.Error("expected docs-layout class on docs list page") } } func TestRewriteLinksSimple(t *testing.T) { input := `link and ext` result := rewriteLinksSimple(input, ".") if !containsStr(result, `/docs/README.md`) { t.Errorf("expected rewritten link, got: %s", result) } if !containsStr(result, `https://example.com`) { t.Error("external link should not be rewritten") } } func TestRewriteLinksSimple_NestedDir(t *testing.T) { input := `link` result := rewriteLinksSimple(input, "agents/coder") if !containsStr(result, `/docs/agents/coder/CLAUDE.md`) { t.Errorf("expected nested rewritten link, got: %s", result) } } // --- T22: Submit tests --- func TestSubmitPage(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/submit", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } body := w.Body.String() if !containsStr(body, "Klijent") { t.Error("expected 'Klijent' mode button") } if !containsStr(body, "Operater") { t.Error("expected 'Operater' mode button") } if !containsStr(body, "mode-client") { t.Error("expected client mode section") } } func TestSubmitPage_ClientModeIsDefault(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/submit", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // Operator mode should be hidden by default if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) { t.Error("expected operator mode to be hidden by default") } } func TestSimpleSubmit_CreatesTask(t *testing.T) { srv := setupTestServer(t) form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok") req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) if resp["status"] != "ok" { t.Errorf("expected status ok, got %v", resp["status"]) } taskID, ok := resp["task_id"].(string) if !ok || taskID == "" { t.Fatal("expected non-empty task_id") } // Verify file was created in backlog path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") content, err := os.ReadFile(path) if err != nil { t.Fatalf("expected task file in backlog: %v", err) } if !containsStr(string(content), "Test prijava") { t.Error("expected title in task file") } if !containsStr(string(content), "Visok") { t.Error("expected priority in task file") } if !containsStr(string(content), "klijent (prijava)") { t.Error("expected 'klijent (prijava)' as creator") } } func TestSimpleSubmit_MissingTitle(t *testing.T) { srv := setupTestServer(t) form := strings.NewReader("description=Samo+opis") req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing title, got %d", w.Code) } } func TestSimpleSubmit_AutoNumbering(t *testing.T) { srv := setupTestServer(t) // Existing tasks: T01 (done), T08 (backlog) // Next should be T09 form := strings.NewReader("title=Novi+task&priority=Srednji") req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) taskID, _ := resp["task_id"].(string) if taskID != "T09" { t.Errorf("expected T09 (next after T08), got %s", taskID) } } func TestSimpleSubmit_DefaultPriority(t *testing.T) { srv := setupTestServer(t) form := strings.NewReader("title=Bez+prioriteta") req := httptest.NewRequest(http.MethodPost, "/submit/simple", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) taskID, _ := resp["task_id"].(string) path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md") content, _ := os.ReadFile(path) if !containsStr(string(content), "Srednji") { t.Error("expected default priority 'Srednji'") } } func TestChatSubmit_NoAPIKey(t *testing.T) { srv := setupTestServer(t) // Ensure no API key is set os.Unsetenv("ANTHROPIC_API_KEY") body := `{"message":"test poruka"}` req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 without API key, got %d: %s", w.Code, w.Body.String()) } } func TestChatSubmit_EmptyMessage(t *testing.T) { srv := setupTestServer(t) os.Setenv("ANTHROPIC_API_KEY", "test-key") defer os.Unsetenv("ANTHROPIC_API_KEY") body := `{"message":""}` req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty message, got %d", w.Code) } } func TestChatStream_NotFound(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestNextTaskNumber(t *testing.T) { dir := t.TempDir() tasksDir := filepath.Join(dir, "TASKS") for _, f := range []string{"backlog", "ready", "active", "review", "done"} { os.MkdirAll(filepath.Join(tasksDir, f), 0755) } os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644) os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644) num, err := nextTaskNumber(tasksDir) if err != nil { t.Fatalf("unexpected error: %v", err) } if num != "T09" { t.Errorf("expected T09, got %s", num) } } func TestBuildTaskContext(t *testing.T) { tasks := []supervisor.Task{ {ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"}, {ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"}, } ctx := buildTaskContext(tasks) if !containsStr(ctx, "T01: Init") { t.Error("expected T01 in context") } if !containsStr(ctx, "T02: Server") { t.Error("expected T02 in context") } if !containsStr(ctx, "DONE") { t.Error("expected DONE section") } } func TestSubmitPage_HasPrijavaNav(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/submit", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, `href="/submit"`) { t.Error("expected Prijava nav link") } } func TestDashboard_HasPrijavaNav(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, `href="/submit"`) { t.Error("expected Prijava nav link in dashboard") } } // --- T21: UI tests --- func TestDashboard_DetailOpenClassInJS(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // JS should add detail-open class when detail panel opens if !containsStr(body, "detail-open") { t.Error("expected 'detail-open' body class in dashboard JS") } } func TestDashboard_ClickOutsideClosesDetail(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // Should have click-outside handler for detail panel if !containsStr(body, "closest('.task-card')") { t.Error("expected click-outside handler for detail panel") } } func TestConsolePage_ToolbarAbovePanels(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() // Toolbar should appear before console-panels in the HTML toolbarIdx := strings.Index(body, "console-toolbar") panelsIdx := strings.Index(body, "console-panels") if toolbarIdx == -1 { t.Fatal("expected console-toolbar in console page") } if panelsIdx == -1 { t.Fatal("expected console-panels in console page") } if toolbarIdx > panelsIdx { t.Error("expected toolbar before panels in console HTML") } } func TestConsolePage_HasSessionToggle(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/console", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "togglePanel2") { t.Error("expected togglePanel2 button in console page") } if !containsStr(body, `+ Sesija 2`) { t.Error("expected '+ Sesija 2' toggle button") } } func TestDocsPage_HasFullHeightLayout(t *testing.T) { srv := setupTestServer(t) req := httptest.NewRequest(http.MethodGet, "/docs", nil) w := httptest.NewRecorder() srv.Router.ServeHTTP(w, req) body := w.Body.String() if !containsStr(body, "docs-container") { t.Error("expected docs-container class") } if !containsStr(body, "docs-layout") { t.Error("expected docs-layout class for grid layout") } } func containsStr(s, substr string) bool { return len(s) >= len(substr) && findStr(s, substr) } func findStr(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } |
|---|