//go:build integration package httpx import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/domain" "bountyboard/internal/store" "bountyboard/internal/workperform" ) // lifecycleStack: admin-created customer, consultant, developer in pool, and // one atomized task ready to publish. type lifecycleStack struct { ts *httptest.Server srv *Server st *store.Store consultant *http.Client consCSRF string consUser domain.User developer *http.Client devCSRF string devUser domain.User task *domain.Task customerID string } func newLifecycleStack(t *testing.T, extraEnv map[string]string) *lifecycleStack { t.Helper() ts, srv, st, _ := newAuthStack(t, extraEnv) cc := newClient(t) consUser := registerUser(t, ts.URL, cc, "lc-cons@example.com", "Lifecycle Cons") promote(t, st, consUser.ID, map[string]bool{"consultant": true}) dc := newClient(t) devUser := registerUser(t, ts.URL, dc, "lc-dev@example.com", "Lifecycle Dev") customer := &domain.Customer{ Name: "LC Customer", Ticketing: domain.Ticketing{Type: domain.TicketingDemo, ProjectKey: "LC"}, ConsultantIDs: []string{consUser.ID}, DefaultBudget: 1000, } if err := st.CreateCustomer(t.Context(), customer); err != nil { t.Fatal(err) } // consultant pools the developer consCSRF := csrfFrom(t, cc, ts.URL) resp := postJSON(t, cc, ts.URL+"/api/v1/consultant/pool", map[string]string{"developerId": devUser.ID}, consCSRF) if resp.StatusCode != http.StatusCreated { t.Fatalf("add to pool: %d", resp.StatusCode) } resp.Body.Close() task := &domain.Task{ CustomerID: customer.ID, ConsultantID: consUser.ID, Origin: domain.OriginSubdivided, Status: domain.StatusAtomized, Title: "Build the CSV importer", Description: "as specified", AcceptanceCriteria: []string{"imports valid rows", "reports bad rows"}, EffortCoefficient: 0.25, Budget: 1000, Bounty: 250, } if err := st.InsertTask(t.Context(), task); err != nil { t.Fatal(err) } return &lifecycleStack{ ts: ts, srv: srv, st: st, consultant: cc, consCSRF: consCSRF, consUser: consUser, developer: dc, devCSRF: csrfFrom(t, dc, ts.URL), devUser: devUser, task: task, customerID: customer.ID, } } func (l *lifecycleStack) taskStatus(t *testing.T) domain.TaskStatus { t.Helper() fresh, err := l.st.TaskByID(t.Context(), l.task.ID) if err != nil { t.Fatal(err) } return fresh.Status } func mustPost(t *testing.T, c *http.Client, url string, body any, csrf string, want int) *http.Response { t.Helper() resp := postJSON(t, c, url, body, csrf) if resp.StatusCode != want { var buf bytes.Buffer buf.ReadFrom(resp.Body) t.Fatalf("POST %s: %d (want %d): %s", url, resp.StatusCode, want, buf.String()) } return resp } func TestFullLifecycleImportedToApproved(t *testing.T) { l := newLifecycleStack(t, nil) base := l.ts.URL + "/api/v1/tasks/" + l.task.ID // consultant publishes mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close() if got := l.taskStatus(t); got != domain.StatusPublished { t.Fatalf("after publish: %s", got) } // developer sees it on the board with the right bounty resp, err := l.developer.Get(l.ts.URL + "/api/v1/board") if err != nil { t.Fatal(err) } var board struct { Tasks []domain.Task `json:"tasks"` } bodyJSON(t, resp, &board) if len(board.Tasks) != 1 || board.Tasks[0].Bounty != 250 { t.Fatalf("board: %+v", board.Tasks) } // developer claims mustPost(t, l.developer, base+"/claim", map[string]string{"note": "I know CSV"}, l.devCSRF, http.StatusNoContent).Body.Close() if got := l.taskStatus(t); got != domain.StatusClaimRequested { t.Fatalf("after claim: %s", got) } // premature lifecycle calls are rejected resp = postJSON(t, l.developer, base+"/start", map[string]any{}, l.devCSRF) if resp.StatusCode != http.StatusForbidden { // not assignee yet t.Fatalf("start before assignment: %d", resp.StatusCode) } resp.Body.Close() // consultant approves the claim mustPost(t, l.consultant, base+"/approve-claim", map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close() if got := l.taskStatus(t); got != domain.StatusAssigned { t.Fatalf("after approve-claim: %s", got) } // developer starts, logs time, comments, submits mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close() mustPost(t, l.developer, base+"/time", map[string]any{"minutes": 90, "note": "parser"}, l.devCSRF, http.StatusCreated).Body.Close() mustPost(t, l.developer, base+"/comments", map[string]any{"body": "
Done, see PR
"}, l.devCSRF, http.StatusCreated).Body.Close() mustPost(t, l.developer, base+"/submit-review", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close() if got := l.taskStatus(t); got != domain.StatusInReview { t.Fatalf("after submit: %s", got) } // comment was sanitized fresh, _ := l.st.TaskByID(t.Context(), l.task.ID) if len(fresh.Comments) != 1 || bytes.Contains([]byte(fresh.Comments[0].Body), []byte("