b1c9a42810
- aggregations over the immutable bountyAwards ledger: totals, weekly buckets ($dateTrunc), per-developer and per-customer groupings - timeline-derived: approval rate, time logged, assigned→approved lead time, imported→published atomization lead time, open board depth - developer + consultant dashboards (admin = global consultant view), date-range filters, CSV export of the ledger - leaderboard (top 10 by bounty) honoring the new leaderboardOptOut profile setting - hand-rolled SVG line and bar charts (~150 lines, no chart library) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
4.0 KiB
Go
138 lines
4.0 KiB
Go
//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)
|
|
}
|
|
}
|