From 2ea20da5ef7a4d7454e0b0d0bb2530365415c66d Mon Sep 17 00:00:00 2001 From: agent-company Date: Sat, 28 Mar 2026 18:12:53 +0000 Subject: [PATCH] test: add 43 integration tests for all HTTP handlers Add comprehensive integration test suite using httptest with a mock Gitea API server. Tests cover GET and POST handlers for dashboard, issues, pulls, issue/PR detail, create issue, state changes, comments, labels, assignees, reviews, and settings. Both regular and HTMX request paths are tested. Includes TestMain to set working directory to project root for template loading. Covers issues: #140 #139 #138 #137 #136 #135 #134 #133 #124 #118 #113 #111 #110 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handlers/integration_test.go | 1066 +++++++++++++++++++++++++ 1 file changed, 1066 insertions(+) create mode 100644 internal/handlers/integration_test.go 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, "