package web_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" "github.com/m/projax/gitea" "github.com/m/projax/web" ) // gwbServer spins up a fake Gitea server with controllable responses for // close/reopen/comment/create + a passthrough issues list. type gwbServer struct { URL string closes atomic.Int32 reopens atomic.Int32 comments atomic.Int32 creates atomic.Int32 srv *httptest.Server repoOwner string repoName string } func newGwbServer(t *testing.T, owner, repo string) *gwbServer { t.Helper() g := &gwbServer{repoOwner: owner, repoName: repo} mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: _, _ = io.WriteString(w, "[]") case http.MethodPost: g.creates.Add(1) var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(201) _, _ = io.WriteString(w, `{"number": 1, "title": "`+body["title"]+`", "state": "open", "updated_at": "2026-05-15T19:00:00Z", "html_url": "https://x/1"}`) } }) mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPatch { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) if body["state"] == "closed" { g.closes.Add(1) } else { g.reopens.Add(1) } w.WriteHeader(201) _, _ = io.WriteString(w, `{}`) } }) mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42/comments", func(w http.ResponseWriter, r *http.Request) { g.comments.Add(1) w.WriteHeader(201) _, _ = io.WriteString(w, `{"id": 1, "body": "test", "html_url": "https://x/1", "created_at": "2026-05-15T19:00:00Z"}`) }) g.srv = httptest.NewServer(mux) g.URL = g.srv.URL t.Cleanup(func() { g.srv.Close() }) return g } // seedItemWithGiteaLink inserts a projax item under "dev" with a gitea-repo // link pointing at owner/repo, and returns the primary path + cleanup. func seedItemWithGiteaLink(t *testing.T, srv *web.Server, repoRef string) (path string, cleanup func()) { t.Helper() // Use the underlying pool directly via test helpers from server_test.go. pool := srv.Store.Pool ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") slug := "gwb-" + stamp var dev, id string if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { t.Fatalf("dev: %v", err) } if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids, management) values (array['project']::text[], 'gwb', $1, ARRAY[$2]::uuid[], ARRAY['mai']) returning id`, slug, dev, ).Scan(&id); err != nil { t.Fatalf("seed item: %v", err) } if _, err := pool.Exec(ctx, `insert into projax.item_links (item_id, ref_type, ref_id, rel) values ($1, 'gitea-repo', $2, 'tracks')`, id, repoRef, ); err != nil { t.Fatalf("seed link: %v", err) } cleanup = func() { _, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) } return "dev." + slug, cleanup } func TestIssuesCloseRoundTrip(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() g := newGwbServer(t, "fake", "repo-close") srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok")) path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-close") defer cleanup() h := srv.Routes() form := url.Values{} form.Set("repo", "fake/repo-close") form.Set("number", "42") req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/close", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Result().StatusCode != 200 { body, _ := io.ReadAll(w.Result().Body) t.Fatalf("close → %d body=%s", w.Result().StatusCode, body) } if g.closes.Load() != 1 { t.Errorf("upstream close count = %d, want 1", g.closes.Load()) } } func TestIssuesCommentRoundTrip(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() g := newGwbServer(t, "fake", "repo-comment") srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok")) path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-comment") defer cleanup() h := srv.Routes() form := url.Values{} form.Set("repo", "fake/repo-comment") form.Set("number", "42") form.Set("body", "looks good") req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/comment", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Result().StatusCode != 200 { body, _ := io.ReadAll(w.Result().Body) t.Fatalf("comment → %d body=%s", w.Result().StatusCode, body) } if g.comments.Load() != 1 { t.Errorf("upstream comment count = %d, want 1", g.comments.Load()) } } func TestIssuesCreateRoundTrip(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() g := newGwbServer(t, "fake", "repo-create") srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok")) path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-create") defer cleanup() h := srv.Routes() form := url.Values{} form.Set("repo", "fake/repo-create") form.Set("title", "New from projax") form.Set("body", "filed via /admin") req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Result().StatusCode != 200 { body, _ := io.ReadAll(w.Result().Body) t.Fatalf("create → %d body=%s", w.Result().StatusCode, body) } if g.creates.Load() != 1 { t.Errorf("upstream create count = %d, want 1", g.creates.Load()) } } func TestIssuesReopenRoundTrip(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() g := newGwbServer(t, "fake", "repo-reopen") srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok")) path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-reopen") defer cleanup() h := srv.Routes() form := url.Values{} form.Set("repo", "fake/repo-reopen") form.Set("number", "42") req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/reopen", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Result().StatusCode != 200 { body, _ := io.ReadAll(w.Result().Body) t.Fatalf("reopen → %d body=%s", w.Result().StatusCode, body) } if g.reopens.Load() != 1 { t.Errorf("upstream reopen count = %d, want 1", g.reopens.Load()) } } func TestIssuesForbiddenRendersInlineBanner(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/fake/repo-403/issues", func(w http.ResponseWriter, r *http.Request) { // GET returns []; POST returns 403. if r.Method == http.MethodGet { _, _ = io.WriteString(w, "[]") return } http.Error(w, "forbidden", http.StatusForbidden) }) fake := httptest.NewServer(mux) defer fake.Close() srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok")) path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-403") defer cleanup() h := srv.Routes() form := url.Values{} form.Set("repo", "fake/repo-403") form.Set("title", "should 403") req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Result().StatusCode != 200 { t.Fatalf("expected graceful 200 with inline banner, got %d", w.Result().StatusCode) } body, _ := io.ReadAll(w.Result().Body) if !strings.Contains(string(body), "lacks write access") { t.Errorf("expected 403 banner about token write access — body:\n%s", string(body)) } }