diff --git a/internal/handlers/integration_test.go b/internal/handlers/integration_test.go new file mode 100644 index 0000000..b5dcd3e --- /dev/null +++ b/internal/handlers/integration_test.go @@ -0,0 +1,1066 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" + giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" +) + +// TestMain changes the working directory to the project root so that +// template files can be found by handlers that use relative paths. +func TestMain(m *testing.M) { + // Walk up from this source file to find the project root (where go.mod lives). + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + break + } + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root without finding go.mod; run tests from cwd. + break + } + dir = parent + } + _ = os.Chdir(dir) + os.Exit(m.Run()) +} + +// mockGiteaAPI starts an httptest server that simulates the Gitea API +// endpoints needed by the handlers. Returns the server and a cleanup func. +func mockGiteaAPI(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + // GET /api/v1/user/orgs — list user orgs. + mux.HandleFunc("GET /api/v1/user/orgs", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"username": "test-org", "full_name": "Test Org", "avatar_url": ""}, + }) + }) + + // GET /api/v1/orgs/{org}/repos — list repos for an org. + mux.HandleFunc("GET /api/v1/orgs/{org}/repos", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "name": "repo1", "full_name": "test-org/repo1", "owner": map[string]string{"login": "test-org"}}, + {"id": 2, "name": "repo2", "full_name": "test-org/repo2", "owner": map[string]string{"login": "test-org"}}, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/issues — list issues. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/issues", func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + if state == "" { + state = "open" + } + json.NewEncoder(w).Encode([]map[string]interface{}{ + { + "id": 1, "number": 1, "title": "Test issue", + "state": state, "body": "Issue body", + "labels": []map[string]interface{}{{"id": 1, "name": "bug", "color": "d73a4a"}}, + "html_url": "http://gitea.example.com/test-org/repo1/issues/1", + }, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/issues/{index} — get single issue. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/issues/{index}", func(w http.ResponseWriter, r *http.Request) { + // Check if this is a comments request. + if strings.HasSuffix(r.URL.Path, "/comments") { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "body": "Test comment", "user": map[string]string{"login": "user1"}, "created_at": "2026-01-01T00:00:00Z"}, + }) + return + } + index := r.PathValue("index") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1, "number": mustAtoi(index), "title": "Test issue #" + index, + "state": "open", "body": "Issue body for #" + index, + "labels": []map[string]interface{}{{"id": 1, "name": "bug", "color": "d73a4a"}}, + "html_url": fmt.Sprintf("http://gitea.example.com/test-org/repo1/issues/%s", index), + "assignees": []interface{}{}, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "body": "Test comment", "user": map[string]string{"login": "user1"}, "created_at": "2026-01-01T00:00:00Z"}, + }) + }) + + // POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments — add comment. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 99, "body": "New comment", "user": map[string]string{"login": "testuser"}, "created_at": "2026-03-28T00:00:00Z", + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/labels — get repo labels. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/labels", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "name": "bug", "color": "d73a4a"}, + {"id": 2, "name": "enhancement", "color": "a2eeef"}, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/collaborators — list collaborators. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/collaborators", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"login": "user1"}, + {"login": "user2"}, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/pulls — list PRs. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/pulls", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{ + { + "id": 1, "number": 10, "title": "Test PR", + "state": "open", "body": "PR body", + "html_url": "http://gitea.example.com/test-org/repo1/pulls/10", + "head": map[string]string{"label": "feature", "ref": "feature"}, + "base": map[string]string{"label": "master", "ref": "master"}, + }, + }) + }) + + // GET /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews — get PR reviews. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{}) + }) + + // GET /api/v1/repos/{owner}/{repo}/pulls/{index} — get single PR. + mux.HandleFunc("GET /api/v1/repos/{owner}/{repo}/pulls/{index}", func(w http.ResponseWriter, r *http.Request) { + index := r.PathValue("index") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1, "number": mustAtoi(index), "title": "Test PR #" + index, + "state": "open", "body": "PR body for #" + index, + "html_url": fmt.Sprintf("http://gitea.example.com/test-org/repo1/pulls/%s", index), + "head": map[string]string{"label": "feature-branch", "ref": "feature-branch"}, + "base": map[string]string{"label": "master", "ref": "master"}, + }) + }) + + // PATCH /api/v1/repos/{owner}/{repo}/issues/{index} — update issue state. + mux.HandleFunc("PATCH /api/v1/repos/{owner}/{repo}/issues/{index}", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "state": "closed"}) + }) + + // POST /api/v1/repos/{owner}/{repo}/issues/{index}/labels — apply labels. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/issues/{index}/labels", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "bug"}}) + }) + + // POST /api/v1/repos/{owner}/{repo}/issues/{index}/assignees — assign issue. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/issues/{index}/assignees", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "assignees": []map[string]string{{"login": "user1"}}}) + }) + + // POST /api/v1/repos/{owner}/{repo}/issues — create issue. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/issues", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 42, "number": 42, "title": "New test issue", "state": "open", + }) + }) + + // POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews — submit review. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "state": "APPROVED"}) + }) + + // POST /api/v1/markdown — render markdown. + mux.HandleFunc("POST /api/v1/markdown", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "

Rendered markdown

") + }) + + return httptest.NewServer(mux) +} + +// newTestHandlerWithMock creates a Handler backed by a mock Gitea API. +func newTestHandlerWithMock(t *testing.T) (*Handler, *httptest.Server) { + t.Helper() + srv := mockGiteaAPI(t) + + cfg := &config.Config{ + GiteaURL: srv.URL, + SessionSecret: "test-secret-that-is-at-least-32-chars-long", + ListenAddr: ":8080", + } + client := giteaclient.NewClient(cfg.GiteaURL) + h := NewHandler(cfg, client) + return h, srv +} + +// reqWithToken creates an HTTP request with a token in the context. +func reqWithToken(method, path string, body string) *http.Request { + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + r = httptest.NewRequest(method, path, nil) + } + ctx := context.WithValue(r.Context(), middleware.TokenContextKey, "test-token") + return r.WithContext(ctx) +} + +func mustAtoi(s string) int { + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} + +// --- Issue #140: Integration tests for GET / dashboard handler --- + +func TestIntegration_Dashboard_WithToken(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + req := reqWithToken(http.MethodGet, "/", "") + w := httptest.NewRecorder() + + h.Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + body := w.Body.String() + if !contains(body, "Dashboard") || !contains(body, "Gitea Mobile") { + t.Error("expected dashboard page content with full page wrapper") + } +} + +func TestIntegration_Dashboard_WithOrgFilter(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + req := reqWithToken(http.MethodGet, "/?org=test-org", "") + w := httptest.NewRecorder() + + h.Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +// --- Issue #139: Integration tests for GET /issues and GET /pulls --- + +func TestIntegration_ListIssues_WithToken(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + req := reqWithToken(http.MethodGet, "/issues", "") + w := httptest.NewRecorder() + + h.ListIssues(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + body := w.Body.String() + if !contains(body, "Issues") { + t.Error("expected issues page content") + } +} + +func TestIntegration_ListIssues_HTMX(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + req := reqWithToken(http.MethodGet, "/issues", "") + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + + h.ListIssues(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + body := w.Body.String() + if contains(body, "