@@ -10,7 +10,10 @@ import (
1010 "os"
1111 "path/filepath"
1212 "strings"
13+ "sync"
14+ "sync/atomic"
1315 "testing"
16+ "time"
1417
1518 "github.com/flashcatcloud/go-flashduty"
1619 "github.com/spf13/cobra"
@@ -820,14 +823,17 @@ func TestCommandIncidentCommentFailsWhenNoCommentEntryFound(t *testing.T) {
820823func TestCommandIncidentCommentChecksEntireBatchNotJustFirstFailure (t * testing.T ) {
821824 saveAndResetGlobals (t )
822825 stub := newGFStub (t )
826+ var mu sync.Mutex
823827 feedCallsByIncident := map [string ]int {}
824828 stub .dataForPath = func (path string , body map [string ]any ) any {
825829 switch path {
826830 case "/incident/comment" :
827831 return map [string ]any {}
828832 case "/incident/feed" :
829833 incID , _ := body ["incident_id" ].(string )
834+ mu .Lock ()
830835 feedCallsByIncident [incID ]++
836+ mu .Unlock ()
831837 switch incID {
832838 case "inc-2" :
833839 return map [string ]any {"items" : []any {
@@ -984,6 +990,163 @@ func TestCommandIncidentCommentVerifiesAcrossFeedPages(t *testing.T) {
984990 }
985991}
986992
993+ // TestCommandIncidentCommentVerifyOrderStableDespiteCompletionOrder guards
994+ // that concurrent verification's problem list comes out in ctx.Args order,
995+ // never completion order. The stub sleeps longer for earlier-indexed
996+ // incidents than later ones, so completion order is guaranteed to be the
997+ // exact reverse of argument order; a test that let requests finish in
998+ // whatever order they naturally would (e.g. all equally fast) could pass
999+ // even with results appended in completion order, since ctx.Args order and
1000+ // completion order would coincidentally match. Forcing the inversion is what
1001+ // makes this a real test of the ordering guarantee rather than of luck.
1002+ func TestCommandIncidentCommentVerifyOrderStableDespiteCompletionOrder (t * testing.T ) {
1003+ saveAndResetGlobals (t )
1004+ stub := newGFStub (t )
1005+
1006+ const n = 4
1007+ ids := make ([]string , n )
1008+ delay := make (map [string ]time.Duration , n )
1009+ for i := range ids {
1010+ ids [i ] = fmt .Sprintf ("inc-%d" , i + 1 )
1011+ // inc-1 sleeps longest, inc-n shortest: completion order is guaranteed
1012+ // to be inc-n, ..., inc-1 — the reverse of ids' (== ctx.Args') order.
1013+ delay [ids [i ]] = time .Duration (n - i ) * 15 * time .Millisecond
1014+ }
1015+ stub .dataForPath = func (path string , body map [string ]any ) any {
1016+ switch path {
1017+ case "/incident/comment" :
1018+ return map [string ]any {}
1019+ case "/incident/feed" :
1020+ incID , _ := body ["incident_id" ].(string )
1021+ time .Sleep (delay [incID ])
1022+ // Empty timeline: every incident fails verification, so every one
1023+ // of them appears in the problem list this test inspects.
1024+ return map [string ]any {"items" : []any {}}
1025+ default :
1026+ return map [string ]any {}
1027+ }
1028+ }
1029+ commentFile := writeCommentFile (t , "the real comment" )
1030+
1031+ args := append ([]string {"incident" , "comment" }, ids ... )
1032+ args = append (args , "--comment-file" , commentFile )
1033+ _ , err := execCommand (args ... )
1034+ if err == nil {
1035+ t .Fatal ("[verify-order] expected a non-zero exit, got nil error" )
1036+ }
1037+
1038+ lastIdx := - 1
1039+ for _ , id := range ids {
1040+ idx := strings .Index (err .Error (), "incident " + id + ":" )
1041+ if idx == - 1 {
1042+ t .Fatalf ("[verify-order] expected %q named in the error: %v" , id , err )
1043+ }
1044+ if idx <= lastIdx {
1045+ t .Fatalf ("[verify-order] problems are not in ctx.Args order (completion order leaked through) in: %v" , err )
1046+ }
1047+ lastIdx = idx
1048+ }
1049+ }
1050+
1051+ // incidentFeedConcurrencyProbe returns a stub.dataForPath function that
1052+ // answers /incident/comment and every /incident/feed request successfully
1053+ // (the feed always carries want as an i_comm entry), sleeping delay on each
1054+ // feed request and recording, via inFlight/maxInFlight, the peak number of
1055+ // feed requests in flight at once. Tests use the recorded peak to prove
1056+ // verifyIncidentCommentsWritten's requests actually overlap (and by how
1057+ // much), which can't be observed from the command's exit code or output
1058+ // alone.
1059+ func incidentFeedConcurrencyProbe (want string , delay time.Duration , inFlight , maxInFlight * atomic.Int32 ) func (path string , body map [string ]any ) any {
1060+ return func (path string , body map [string ]any ) any {
1061+ switch path {
1062+ case "/incident/comment" :
1063+ return map [string ]any {}
1064+ case "/incident/feed" :
1065+ cur := inFlight .Add (1 )
1066+ defer inFlight .Add (- 1 )
1067+ for {
1068+ peak := maxInFlight .Load ()
1069+ if cur <= peak || maxInFlight .CompareAndSwap (peak , cur ) {
1070+ break
1071+ }
1072+ }
1073+ time .Sleep (delay )
1074+ return map [string ]any {"items" : []any {
1075+ map [string ]any {"type" : "i_comm" , "created_at" : 1 , "detail" : map [string ]any {"comment" : want }},
1076+ }}
1077+ default :
1078+ return map [string ]any {}
1079+ }
1080+ }
1081+ }
1082+
1083+ // TestCommandIncidentCommentVerifyRunsConcurrently guards against a future
1084+ // refactor silently reverting verifyIncidentCommentsWritten to a sequential
1085+ // walk: with 4 incidents each sleeping on their /incident/feed request, a
1086+ // sequential implementation would never have more than one request in flight
1087+ // at once, while a correctly concurrent one will. Nothing about the command's
1088+ // exit code or output would catch that regression — only observing overlap
1089+ // directly, via incidentFeedConcurrencyProbe, does.
1090+ func TestCommandIncidentCommentVerifyRunsConcurrently (t * testing.T ) {
1091+ saveAndResetGlobals (t )
1092+ stub := newGFStub (t )
1093+ var inFlight , maxInFlight atomic.Int32
1094+ stub .dataForPath = incidentFeedConcurrencyProbe ("the real comment" , 20 * time .Millisecond , & inFlight , & maxInFlight )
1095+
1096+ const n = 4
1097+ ids := make ([]string , n )
1098+ for i := range ids {
1099+ ids [i ] = fmt .Sprintf ("inc-%d" , i + 1 )
1100+ }
1101+ commentFile := writeCommentFile (t , "the real comment" )
1102+
1103+ args := append ([]string {"incident" , "comment" }, ids ... )
1104+ args = append (args , "--comment-file" , commentFile )
1105+ out , err := execCommand (args ... )
1106+ if err != nil {
1107+ t .Fatalf ("[verify-concurrent] unexpected error: %v" , err )
1108+ }
1109+ if ! strings .Contains (out , fmt .Sprintf ("Commented on %d incident(s)." , n )) {
1110+ t .Fatalf ("[verify-concurrent] unexpected output:\n %s" , out )
1111+ }
1112+ if got := maxInFlight .Load (); got < 2 {
1113+ t .Fatalf ("[verify-concurrent] observed peak in-flight verify requests = %d, want > 1 (verification never actually overlapped)" , got )
1114+ }
1115+ }
1116+
1117+ // TestCommandIncidentCommentVerifyRespectsConcurrencyBound guards the other
1118+ // side of the same fan-out: with 3x maxIncidentVerifyConcurrency incidents,
1119+ // each holding its /incident/feed request open for a while, the peak observed
1120+ // in flight must never exceed the worker limit — a regression that dropped
1121+ // the semaphore (e.g. an unbounded "go func" per incident) would fire every
1122+ // request at once and blow past it.
1123+ func TestCommandIncidentCommentVerifyRespectsConcurrencyBound (t * testing.T ) {
1124+ saveAndResetGlobals (t )
1125+ stub := newGFStub (t )
1126+ var inFlight , maxInFlight atomic.Int32
1127+ stub .dataForPath = incidentFeedConcurrencyProbe ("the real comment" , 20 * time .Millisecond , & inFlight , & maxInFlight )
1128+
1129+ const n = maxIncidentVerifyConcurrency * 3
1130+ ids := make ([]string , n )
1131+ for i := range ids {
1132+ ids [i ] = fmt .Sprintf ("inc-%d" , i + 1 )
1133+ }
1134+ commentFile := writeCommentFile (t , "the real comment" )
1135+
1136+ args := append ([]string {"incident" , "comment" }, ids ... )
1137+ args = append (args , "--comment-file" , commentFile )
1138+ out , err := execCommand (args ... )
1139+ if err != nil {
1140+ t .Fatalf ("[verify-bound] unexpected error: %v" , err )
1141+ }
1142+ if ! strings .Contains (out , fmt .Sprintf ("Commented on %d incident(s)." , n )) {
1143+ t .Fatalf ("[verify-bound] unexpected output:\n %s" , out )
1144+ }
1145+ if got := maxInFlight .Load (); got > maxIncidentVerifyConcurrency {
1146+ t .Fatalf ("[verify-bound] observed peak in-flight verify requests = %d, want <= %d (the worker limit)" , got , maxIncidentVerifyConcurrency )
1147+ }
1148+ }
1149+
9871150// TestCommandIncidentLifecycleRejectsMoreThan100IDs covers the curated
9881151// commands that still enforce the 100-id batch cap client-side. unack and wake
9891152// were dropped in favor of their generated twins, which carry no client-side
0 commit comments