//go:build integration package httpx import ( "io" "net/http" "strings" "testing" "bountyboard/internal/store" ) func seedAwards(t *testing.T, st *store.Store, devID, consID, custID string, amounts []float64) { t.Helper() for i, amt := range amounts { a := &store.BountyAward{ TaskID: "01JTASK" + strings.Repeat("0", 18-len(string(rune(i)))) + string(rune('A'+i)), DeveloperID: devID, ConsultantID: consID, CustomerID: custID, Amount: amt, Coefficient: 0.25, } if err := st.InsertAward(t.Context(), a); err != nil { t.Fatal(err) } } } func TestDeveloperMetricsAndCSV(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) dc := newClient(t) dev := registerUser(t, ts.URL, dc, "metrics-dev@example.com", "Metrics Dev") seedAwards(t, st, dev.ID, "01JCONS", "01JCUST", []float64{250, 100, 50.5}) resp, err := dc.Get(ts.URL + "/api/v1/developer/metrics") if err != nil { t.Fatal(err) } var out struct { TotalBounty float64 `json:"totalBounty"` TasksCompleted int64 `json:"tasksCompleted"` Weekly []struct { Amount float64 `json:"amount"` } `json:"weekly"` } bodyJSON(t, resp, &out) if out.TotalBounty != 400.5 || out.TasksCompleted != 3 { t.Fatalf("totals: %+v", out) } if len(out.Weekly) == 0 { t.Fatal("weekly series empty") } // CSV export resp, err = dc.Get(ts.URL + "/api/v1/developer/metrics?format=csv") if err != nil { t.Fatal(err) } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") { t.Fatalf("csv content type: %q", ct) } body, _ := io.ReadAll(resp.Body) lines := strings.Split(strings.TrimSpace(string(body)), "\n") if len(lines) != 4 { // header + 3 rows t.Fatalf("csv lines = %d: %s", len(lines), body) } if !strings.HasPrefix(lines[0], "awardedAt,taskId,developer") { t.Fatalf("csv header: %s", lines[0]) } } func TestLeaderboardOptOut(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c1 := newClient(t) dev1 := registerUser(t, ts.URL, c1, "lb1@example.com", "LB One") csrf1 := csrfFrom(t, c1, ts.URL) c2 := newClient(t) dev2 := registerUser(t, ts.URL, c2, "lb2@example.com", "LB Two") seedAwards(t, st, dev1.ID, "01JCONS", "01JCUST", []float64{500}) seedAwards(t, st, dev2.ID, "01JCONS", "01JCUST", []float64{300}) resp, err := c1.Get(ts.URL + "/api/v1/leaderboard") if err != nil { t.Fatal(err) } var out struct { Leaderboard []struct { DeveloperID string `json:"developerId"` Amount float64 `json:"amount"` } `json:"leaderboard"` } bodyJSON(t, resp, &out) if len(out.Leaderboard) != 2 || out.Leaderboard[0].DeveloperID != dev1.ID { t.Fatalf("leaderboard: %+v", out.Leaderboard) } // dev1 opts out → only dev2 remains resp = patchJSON(t, c1, ts.URL+"/api/v1/profile", map[string]any{"settings": map[string]any{"leaderboardOptOut": true}}, csrf1) if resp.StatusCode != http.StatusOK { t.Fatalf("opt out: %d", resp.StatusCode) } resp.Body.Close() resp, _ = c1.Get(ts.URL + "/api/v1/leaderboard") bodyJSON(t, resp, &out) if len(out.Leaderboard) != 1 || out.Leaderboard[0].DeveloperID != dev2.ID { t.Fatalf("leaderboard after opt-out: %+v", out.Leaderboard) } } func TestConsultantMetricsScope(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) cc := newClient(t) cons := registerUser(t, ts.URL, cc, "m-cons@example.com", "M Cons") promote(t, st, cons.ID, map[string]bool{"consultant": true}) otherCons := "01JOTHERCONSULTANT0000000X" seedAwards(t, st, "01JDEVA", cons.ID, "01JCUSTA", []float64{100, 200}) seedAwards(t, st, "01JDEVB", otherCons, "01JCUSTB", []float64{999}) resp, err := cc.Get(ts.URL + "/api/v1/consultant/metrics") if err != nil { t.Fatal(err) } var out struct { TotalBounty float64 `json:"totalBounty"` PerDeveloper []struct { Amount float64 `json:"amount"` } `json:"perDeveloper"` } bodyJSON(t, resp, &out) // only own-consultant awards counted (999 from the other consultant excluded) if out.TotalBounty != 300 { t.Fatalf("consultant total = %v, want 300", out.TotalBounty) } }