05dc91410f
- Board visibility (§4.4): developers now see tasks for any customer with a consultant who pooled them, not only tasks whose consultantId is in their pool. Fixes a published task being invisible to its own author who is both consultant and developer. Adds TestBoardVisibilityIsCustomerBased. - Chat widget: working ✕ close, distinct (non-ghost) header buttons, reliable conversation switching, ← Back, title ellipsis. - Atomization: atomized/imported subtasks now expose a Subdivide button. - Static assets: serve with ETag + Cache-Control: no-cache so JS/CSS fixes reach clients immediately instead of being masked by stale max-age caching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
496 lines
17 KiB
Go
496 lines
17 KiB
Go
//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": "<p>Done, see <b>PR</b> <script>alert(1)</script></p>"},
|
|
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("<script")) {
|
|
t.Fatalf("comment sanitization: %+v", fresh.Comments)
|
|
}
|
|
if len(fresh.TimeLog) != 1 || fresh.TimeLog[0].Minutes != 90 {
|
|
t.Fatalf("time log: %+v", fresh.TimeLog)
|
|
}
|
|
|
|
// consultant reviews in the queue, then requests changes
|
|
qResp, err := l.consultant.Get(l.ts.URL + "/api/v1/consultant/reviews")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var queue struct {
|
|
Tasks []domain.Task `json:"tasks"`
|
|
}
|
|
bodyJSON(t, qResp, &queue)
|
|
if len(queue.Tasks) != 1 {
|
|
t.Fatalf("review queue: %d tasks", len(queue.Tasks))
|
|
}
|
|
mustPost(t, l.consultant, base+"/review",
|
|
map[string]any{"decision": "request_changes", "note": "handle BOM"},
|
|
l.consCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusChangesRequested {
|
|
t.Fatalf("after request_changes: %s", got)
|
|
}
|
|
|
|
// developer resumes and resubmits; consultant approves with checklist
|
|
mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.developer, base+"/submit-review", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.consultant, base+"/review", map[string]any{
|
|
"decision": "approve", "note": "nice",
|
|
"checklist": []map[string]any{
|
|
{"criterion": "imports valid rows", "ok": true},
|
|
{"criterion": "reports bad rows", "ok": true},
|
|
},
|
|
}, l.consCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusApproved {
|
|
t.Fatalf("after approve: %s", got)
|
|
}
|
|
|
|
// immutable bountyAwards row exists with the frozen amount
|
|
var award store.BountyAward
|
|
if err := l.st.DB.Collection("bountyAwards").FindOne(t.Context(),
|
|
bson.M{"taskId": l.task.ID}).Decode(&award); err != nil {
|
|
t.Fatalf("award row: %v", err)
|
|
}
|
|
if award.DeveloperID != l.devUser.ID || award.Amount != 250 || award.Coefficient != 0.25 {
|
|
t.Fatalf("award: %+v", award)
|
|
}
|
|
|
|
// the review checklist landed on the timeline (§11.18)
|
|
fresh, _ = l.st.TaskByID(t.Context(), l.task.ID)
|
|
var checklistOK bool
|
|
for _, e := range fresh.Timeline {
|
|
if e.Event == "approved" && e.Data["checklist"] != nil {
|
|
checklistOK = true
|
|
}
|
|
}
|
|
if !checklistOK {
|
|
t.Error("timeline missing approval checklist")
|
|
}
|
|
|
|
// developer got the approval notification
|
|
notifs, _ := l.st.ListNotifications(t.Context(), l.devUser.ID, false, "", 50)
|
|
var approvedNotif bool
|
|
for _, n := range notifs {
|
|
if n.Kind == "approved" {
|
|
approvedNotif = true
|
|
}
|
|
}
|
|
if !approvedNotif {
|
|
t.Error("developer missing approval notification")
|
|
}
|
|
|
|
// approved task is immutable
|
|
resp = patchJSON(t, l.consultant, base, map[string]any{"budget": 9999.0}, l.consCSRF)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("edit after approve: %d, want 409", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestDeclineWithdrawUnassignAbandon(t *testing.T) {
|
|
l := newLifecycleStack(t, nil)
|
|
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
|
|
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
|
|
|
|
// claim + consultant declines → back to published, dev notified
|
|
mustPost(t, l.developer, base+"/claim", map[string]string{"note": ""}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.consultant, base+"/decline-claim",
|
|
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusPublished {
|
|
t.Fatalf("after decline: %s", got)
|
|
}
|
|
notifs, _ := l.st.ListNotifications(t.Context(), l.devUser.ID, false, "", 50)
|
|
var declined bool
|
|
for _, n := range notifs {
|
|
if n.Kind == "claim_declined" {
|
|
declined = true
|
|
}
|
|
}
|
|
if !declined {
|
|
t.Error("declined developer was not notified")
|
|
}
|
|
|
|
// claim + withdraw → back to published
|
|
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.developer, base+"/claim/withdraw", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusPublished {
|
|
t.Fatalf("after withdraw: %s", got)
|
|
}
|
|
|
|
// assign → unassign by consultant → published again
|
|
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.consultant, base+"/approve-claim",
|
|
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.consultant, base+"/unassign", map[string]any{}, l.consCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusPublished {
|
|
t.Fatalf("after unassign: %s", got)
|
|
}
|
|
fresh, _ := l.st.TaskByID(t.Context(), l.task.ID)
|
|
if fresh.Assignee != nil {
|
|
t.Fatalf("assignee not cleared: %+v", fresh.Assignee)
|
|
}
|
|
|
|
// assign again → developer abandons mid-progress → published
|
|
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.consultant, base+"/approve-claim",
|
|
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
mustPost(t, l.developer, base+"/abandon", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusPublished {
|
|
t.Fatalf("after abandon: %s", got)
|
|
}
|
|
}
|
|
|
|
// TestBoardVisibilityIsCustomerBased: a developer pooled by consultant A
|
|
// sees tasks published by consultant B on the same customer (§4.4 — all
|
|
// consultants of a customer manage its tasks).
|
|
func TestBoardVisibilityIsCustomerBased(t *testing.T) {
|
|
l := newLifecycleStack(t, nil)
|
|
|
|
// second consultant on the same customer publishes their own task
|
|
bc := newClient(t)
|
|
consB := registerUser(t, l.ts.URL, bc, "lc-cons-b@example.com", "Cons B")
|
|
promote(t, l.st, consB.ID, map[string]bool{"consultant": true})
|
|
if _, err := l.st.DB.Collection("customers").UpdateOne(t.Context(),
|
|
bson.M{"_id": l.customerID},
|
|
bson.M{"$push": bson.M{"consultantIds": consB.ID}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
taskB := &domain.Task{
|
|
CustomerID: l.customerID, ConsultantID: consB.ID,
|
|
Origin: domain.OriginSubdivided, Status: domain.StatusAtomized,
|
|
Title: "Task owned by consultant B", EffortCoefficient: 0.5, Budget: 1000, Bounty: 500,
|
|
}
|
|
if err := l.st.InsertTask(t.Context(), taskB); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bCSRF := csrfFrom(t, bc, l.ts.URL)
|
|
mustPost(t, bc, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/publish", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
|
|
|
|
// the developer is pooled ONLY by consultant A, yet sees B's task
|
|
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)
|
|
found := false
|
|
for _, tk := range board.Tasks {
|
|
if tk.ID == taskB.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("developer pooled by consultant A cannot see consultant B's task on the same customer: %+v", board.Tasks)
|
|
}
|
|
|
|
// and can claim it
|
|
mustPost(t, l.developer, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/claim",
|
|
map[string]string{"note": "cross-consultant"}, l.devCSRF, http.StatusNoContent).Body.Close()
|
|
}
|
|
|
|
func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
|
|
l := newLifecycleStack(t, nil)
|
|
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
|
|
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
|
|
|
|
stranger := newClient(t)
|
|
registerUser(t, l.ts.URL, stranger, "stranger@example.com", "Stranger")
|
|
strangerCSRF := csrfFrom(t, stranger, l.ts.URL)
|
|
|
|
resp, err := stranger.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) != 0 {
|
|
t.Fatalf("stranger sees %d tasks", len(board.Tasks))
|
|
}
|
|
resp = postJSON(t, stranger, base+"/claim", map[string]string{}, strangerCSRF)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("stranger claim: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestAIAssignmentAndCallback(t *testing.T) {
|
|
const wpToken = "test-wp-token-abc"
|
|
|
|
// fake work performer + artifact host
|
|
artifact := []byte("diff --git a/x b/x\n+fixed\n")
|
|
var fake *httptest.Server
|
|
fake = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == "POST" && r.URL.Path == "/v1/jobs":
|
|
if r.Header.Get("Authorization") != "Bearer "+wpToken {
|
|
http.Error(w, "bad token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
json.NewEncoder(w).Encode(map[string]string{"jobId": "wp_test_1", "status": "queued"})
|
|
case r.URL.Path == "/healthz":
|
|
w.WriteHeader(http.StatusOK)
|
|
case r.URL.Path == "/artifacts/patch.diff":
|
|
w.Write(artifact)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer fake.Close()
|
|
|
|
l := newLifecycleStack(t, map[string]string{"WORK_PERFORMER_TOKEN": wpToken})
|
|
l.srv.SetPerformerClient(workperform.New(func() string { return fake.URL }, wpToken, 5*time.Second))
|
|
|
|
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
|
|
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
|
|
|
|
// consultant assigns to AI
|
|
resp := mustPost(t, l.consultant, base+"/assign-ai",
|
|
map[string]any{"context": map[string]string{"instructions": "be careful"}},
|
|
l.consCSRF, http.StatusAccepted)
|
|
var accepted struct {
|
|
JobID string `json:"jobId"`
|
|
}
|
|
bodyJSON(t, resp, &accepted)
|
|
if accepted.JobID != "wp_test_1" {
|
|
t.Fatalf("jobId = %q", accepted.JobID)
|
|
}
|
|
if got := l.taskStatus(t); got != domain.StatusAssigned {
|
|
t.Fatalf("after assign-ai: %s", got)
|
|
}
|
|
|
|
// the service calls back with an HMAC-signed result
|
|
cb := workperform.Callback{
|
|
JobID: "wp_test_1", TaskID: l.task.ID, Status: "succeeded",
|
|
Summary: "Implemented the importer", Log: "ran fine",
|
|
}
|
|
cb.Artifacts = []struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
}{{Name: "patch.diff", URL: fake.URL + "/artifacts/patch.diff"}}
|
|
body, _ := json.Marshal(cb)
|
|
|
|
post := func(sig string) *http.Response {
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
l.ts.URL+"/api/v1/internal/work-results", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Signature", sig)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// wrong signature rejected
|
|
r1 := post("deadbeef")
|
|
if r1.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("bad signature: %d", r1.StatusCode)
|
|
}
|
|
r1.Body.Close()
|
|
|
|
// valid signature moves the task to in_review with ingested artifact
|
|
r2 := post(workperform.Sign(wpToken, body))
|
|
if r2.StatusCode != http.StatusOK {
|
|
t.Fatalf("callback: %d", r2.StatusCode)
|
|
}
|
|
r2.Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusInReview {
|
|
t.Fatalf("after callback: %s", got)
|
|
}
|
|
fresh, _ := l.st.TaskByID(t.Context(), l.task.ID)
|
|
var stored *domain.TaskAttachment
|
|
for i := range fresh.Attachments {
|
|
if fresh.Attachments[i].Name == "patch.diff" {
|
|
stored = &fresh.Attachments[i]
|
|
}
|
|
}
|
|
if stored == nil || stored.FileID == "" {
|
|
t.Fatalf("artifact not ingested: %+v", fresh.Attachments)
|
|
}
|
|
|
|
// duplicate callback is idempotent (no error, no double transition)
|
|
r3 := post(workperform.Sign(wpToken, body))
|
|
if r3.StatusCode != http.StatusOK {
|
|
t.Fatalf("duplicate callback: %d", r3.StatusCode)
|
|
}
|
|
var dup map[string]string
|
|
bodyJSON(t, r3, &dup)
|
|
if dup["status"] != "duplicate_ignored" {
|
|
t.Fatalf("duplicate callback status: %v", dup)
|
|
}
|
|
|
|
// consultant reviews the AI work exactly like a human's
|
|
mustPost(t, l.consultant, base+"/review",
|
|
map[string]any{"decision": "approve", "note": "AI did fine"},
|
|
l.consCSRF, http.StatusNoContent).Body.Close()
|
|
if got := l.taskStatus(t); got != domain.StatusApproved {
|
|
t.Fatalf("after AI approve: %s", got)
|
|
}
|
|
// no bounty award for AI assignees
|
|
n, _ := l.st.DB.Collection("bountyAwards").CountDocuments(t.Context(), bson.M{"taskId": l.task.ID})
|
|
if n != 0 {
|
|
t.Fatalf("AI task produced %d award rows, want 0", n)
|
|
}
|
|
}
|