package worker import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" "gitea-codex-bot/internal/config" "gitea-codex-bot/internal/domain" "gitea-codex-bot/internal/gitea" "gitea-codex-bot/internal/httpapi" "gitea-codex-bot/internal/store/sqlstore" ) type e2eRunner struct { mu sync.Mutex headSHA string } func (r *e2eRunner) Run(_ context.Context, pr domain.PullRequestContext, _ domain.ParsedCommand, _ domain.RepoReviewConfig) (domain.ReviewResult, error) { r.mu.Lock() r.headSHA = pr.HeadSHA r.mu.Unlock() return domain.ReviewResult{Verdict: "correct", Confidence: 0.99, Summary: "fake review completed", MarkdownComment: "Fake review completed.", Findings: []domain.Finding{}}, nil } func TestWebhookToWorkerToGiteaWithFakeRunner(t *testing.T) { var commentsMu sync.Mutex var comments []string giteaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case strings.Contains(r.URL.Path, "/pulls/"): _, _ = io.WriteString(w, `{"base":{"ref":"main","sha":"base-sha","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"head":{"ref":"feature","sha":"head-sha","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"html_url":"https://gitea.test/acme/repo/pulls/9"}`) case strings.Contains(r.URL.Path, "/contents/"): http.NotFound(w, r) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/comments"): body, _ := io.ReadAll(r.Body) var payload map[string]string _ = json.Unmarshal(body, &payload) commentsMu.Lock() comments = append(comments, payload["body"]) commentID := 100 + len(comments) commentsMu.Unlock() _, _ = io.WriteString(w, `{"id":`+jsonNumber(commentID)+`}`) default: http.NotFound(w, r) } })) defer giteaServer.Close() settings := config.Settings{GiteaBaseURL: giteaServer.URL, GiteaToken: "test-token", GiteaBotUsername: "codex-bot", GiteaWebhookSecret: "secret", AllowedRepos: []string{"acme/repo"}, DatabaseURL: "sqlite://" + t.TempDir() + "/e2e.db", CooldownSeconds: 60, MaxDiffBytes: 200000, MaxReviewMinutes: 1, Concurrency: 1, WebhookMaxBytes: 1 << 20} st, err := sqlstore.Open(settings) if err != nil { t.Fatal(err) } defer st.Close() if err := st.Migrate(context.Background()); err != nil { t.Fatal(err) } runner := &e2eRunner{} logger := slog.New(slog.NewTextHandler(io.Discard, nil)) client := gitea.NewClient(settings) worker := New(settings, st, client, runner, logger) workerCtx, cancelWorker := context.WithCancel(context.Background()) workerDone := make(chan struct{}) go func() { defer close(workerDone); _ = worker.Run(workerCtx) }() defer func() { cancelWorker(); <-workerDone }() apiServer := httptest.NewServer(httpapi.New(settings, st, client, logger)) defer apiServer.Close() payload := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":901,"body":"@codex review security"},"issue":{"number":9,"pull_request":{"url":"fake"}},"pull_request":{"head":{"sha":"head-sha"}}}`) mac := hmac.New(sha256.New, []byte(settings.GiteaWebhookSecret)) _, _ = mac.Write(payload) req, err := http.NewRequest(http.MethodPost, apiServer.URL+"/webhook/gitea", strings.NewReader(string(payload))) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Gitea-Event", "issue_comment") req.Header.Set("X-Gitea-Delivery", "e2e-901") req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil))) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("webhook status: %d", resp.StatusCode) } deadline := time.Now().Add(5 * time.Second) var job domain.Job for time.Now().Before(deadline) { candidate, queryErr := st.LatestJob(context.Background()) if queryErr != nil { t.Fatal(queryErr) } if candidate != nil { job = *candidate if job.Status == domain.JobSucceeded { break } } time.Sleep(50 * time.Millisecond) } if job.Status != domain.JobSucceeded { t.Fatalf("job did not succeed: %#v", job) } runner.mu.Lock() observedHead := runner.headSHA runner.mu.Unlock() if observedHead != "head-sha" { t.Fatalf("runner reviewed %q instead of queued head", observedHead) } commentID, err := st.BotCommentID(context.Background(), "acme/repo", 9, "codex-review") if err != nil { t.Fatal(err) } if commentID <= 0 { t.Fatalf("missing bot comment mapping: %d", commentID) } commentsMu.Lock() defer commentsMu.Unlock() if len(comments) < 2 || !strings.Contains(comments[len(comments)-1], "codex-review:head_sha=head-sha") { t.Fatalf("unexpected comments: %#v", comments) } } func jsonNumber(value int) string { return strconv.Itoa(value) } var _ domain.ReviewRunner = (*e2eRunner)(nil)