package server import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/dal/kaos/internal/config" ) 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) 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 "Premesti u Ready" 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, "Ready") { t.Error("expected 'Ready' move 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 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 }