@@ -774,6 +774,149 @@ func TestPublishTestFlightUploadReportsStructuredRecoveryResultAfterBetaReviewFa
774774 }
775775}
776776
777+ func TestPublishTestFlightUploadTestNotesFailurePreservesStructuredRecoveryContext (t * testing.T ) {
778+ restore := overridePublishCommandTestHooks (t )
779+ defer restore ()
780+ testNotes := "Line one\n Line two\x1b [31m"
781+
782+ getPublishASCClientFn = func (time.Duration ) (* asc.Client , error ) { return newPublishCommandTestClient (t ), nil }
783+ resolvePublishAppIDWithLookupFn = func (_ context.Context , _ * asc.Client , _ string ) (string , error ) {
784+ return "app-123" , nil
785+ }
786+ validatePublishIPAPathFn = func (string ) (os.FileInfo , error ) {
787+ return newPublishTestFileInfo (t )
788+ }
789+ uploadCalls := 0
790+ uploadBuildAndWaitForIDFn = func (_ context.Context , _ * asc.Client , _ string , _ string , _ os.FileInfo , version , buildNumber string , _ asc.Platform , _ time.Duration , _ time.Duration , _ bool ) (* publishUploadResult , error ) {
791+ uploadCalls ++
792+ return & publishUploadResult {
793+ Build : & asc.BuildResponse {Data : asc.Resource [asc.BuildAttributes ]{
794+ ID : "build-123" ,
795+ Attributes : asc.BuildAttributes {
796+ Version : buildNumber ,
797+ ProcessingState : asc .BuildProcessingStateProcessing ,
798+ },
799+ }},
800+ Version : version ,
801+ BuildNumber : buildNumber ,
802+ }, nil
803+ }
804+ waitForPublishBuildProcessingFn = func (context.Context , * asc.Client , string , time.Duration ) (* asc.BuildResponse , error ) {
805+ return & asc.BuildResponse {Data : asc.Resource [asc.BuildAttributes ]{
806+ ID : "build-123" ,
807+ Attributes : asc.BuildAttributes {
808+ Version : "42" ,
809+ ProcessingState : asc .BuildProcessingStateValid ,
810+ },
811+ }}, nil
812+ }
813+
814+ originalTransport := http .DefaultTransport
815+ t .Cleanup (func () { http .DefaultTransport = originalTransport })
816+ requestCount := 0
817+ http .DefaultTransport = publishCommandRoundTripFunc (func (req * http.Request ) (* http.Response , error ) {
818+ requestCount ++
819+ switch requestCount {
820+ case 1 :
821+ if req .Method != http .MethodGet || req .URL .Path != "/v1/apps/app-123/betaGroups" {
822+ t .Fatalf ("unexpected request %d: %s %s" , requestCount , req .Method , req .URL .String ())
823+ }
824+ return publishCommandJSONResponse (http .StatusOK , `{"data":[{"type":"betaGroups","id":"group-1","attributes":{"name":"External","isInternalGroup":false}}]}` )
825+ case 2 :
826+ if req .Method != http .MethodGet || req .URL .Path != "/v1/builds/build-123/betaBuildLocalizations" {
827+ t .Fatalf ("unexpected request %d: %s %s" , requestCount , req .Method , req .URL .String ())
828+ }
829+ return publishCommandJSONResponse (http .StatusOK , `{"data":[]}` )
830+ case 3 :
831+ if req .Method != http .MethodPost || req .URL .Path != "/v1/betaBuildLocalizations" {
832+ t .Fatalf ("unexpected request %d: %s %s" , requestCount , req .Method , req .URL .String ())
833+ }
834+ return publishCommandJSONResponse (http .StatusUnprocessableEntity , `{"errors":[{"status":"422","code":"ENTITY_ERROR.ATTRIBUTE.INVALID","title":"The provided entity has an invalid attribute","detail":"What to Test was rejected by the server"}]}` )
835+ default :
836+ t .Fatalf ("unexpected request %d: %s %s" , requestCount , req .Method , req .URL .String ())
837+ return nil , nil
838+ }
839+ })
840+
841+ cmd := PublishTestFlightCommand ()
842+ cmd .FlagSet .SetOutput (io .Discard )
843+ if err := cmd .FlagSet .Parse ([]string {
844+ "--app" , "app-123" ,
845+ "--ipa" , "Demo.ipa" ,
846+ "--version" , "1.2.3" ,
847+ "--build-number" , "42" ,
848+ "--group" , "External" ,
849+ "--test-notes" , testNotes ,
850+ "--locale" , "en-US" ,
851+ "--output" , "json" ,
852+ }); err != nil {
853+ t .Fatalf ("parse flags: %v" , err )
854+ }
855+
856+ var runErr error
857+ stdout , _ := capturePublishCommandOutput (t , func () error {
858+ runErr = cmd .Exec (context .Background (), nil )
859+ return runErr
860+ })
861+ if runErr == nil {
862+ t .Fatal ("expected post-upload test notes rejection" )
863+ }
864+ if uploadCalls != 1 {
865+ t .Fatalf ("upload calls = %d, want 1" , uploadCalls )
866+ }
867+ wantParts := []string {
868+ `build "build-123" is available` ,
869+ `locale "en-US"` ,
870+ "The provided entity has an invalid attribute: What to Test was rejected by the server" ,
871+ "retry without uploading the build again" ,
872+ "reuse the original notes" ,
873+ "asc builds test-notes create --build-id BUILD_ID --locale LOCALE --whats-new NOTES" ,
874+ }
875+ for _ , want := range wantParts {
876+ if ! strings .Contains (runErr .Error (), want ) {
877+ t .Fatalf ("expected recovery error to contain %q, got %v" , want , runErr )
878+ }
879+ }
880+ if asc .HasInterpretedTerminalSequence (runErr .Error ()) || strings .Contains (runErr .Error (), "Line one" ) {
881+ t .Fatalf ("expected sanitized human recovery without embedded notes, got %q" , runErr )
882+ }
883+
884+ var result asc.TestFlightPublishResult
885+ if err := json .Unmarshal ([]byte (stdout ), & result ); err != nil {
886+ t .Fatalf ("decode partial publish result: %v\n stdout=%s" , err , stdout )
887+ }
888+ if result .Status != publishPartialStatus || result .FailureStage != publishFailureStageTestNotes {
889+ t .Fatalf ("unexpected partial status: status=%q stage=%q" , result .Status , result .FailureStage )
890+ }
891+ if result .BuildID != "build-123" || ! result .Uploaded {
892+ t .Fatalf ("expected recoverable uploaded build, got buildId=%q uploaded=%t" , result .BuildID , result .Uploaded )
893+ }
894+ wantCompleted := []string {publishCompletedStageUpload , publishCompletedStageBuildProcessing }
895+ if ! slices .Equal (result .CompletedStages , wantCompleted ) {
896+ t .Fatalf ("completed stages = %v, want %v" , result .CompletedStages , wantCompleted )
897+ }
898+ for _ , want := range wantParts {
899+ if ! strings .Contains (result .Failure , want ) {
900+ t .Fatalf ("expected structured failure to contain %q, got %q" , want , result .Failure )
901+ }
902+ }
903+ if result .Recovery == nil {
904+ t .Fatalf ("expected typed test-notes recovery, got %#v" , result )
905+ }
906+ if result .Recovery .BuildID != "build-123" || result .Recovery .Locale != "en-US" || result .Recovery .SubmittedNotes != testNotes {
907+ t .Fatalf ("structured recovery lost exact values: %#v" , result .Recovery )
908+ }
909+ wantArguments := []string {
910+ "builds" , "test-notes" , "create" ,
911+ "--build-id" , "build-123" ,
912+ "--locale" , "en-US" ,
913+ "--whats-new" , testNotes ,
914+ }
915+ if result .Recovery .Command != "asc" || ! slices .Equal (result .Recovery .Arguments , wantArguments ) {
916+ t .Fatalf ("structured recovery command = %q %#v, want asc %#v" , result .Recovery .Command , result .Recovery .Arguments , wantArguments )
917+ }
918+ }
919+
777920func TestPublishTestFlightUploadReportsTerminalProcessingStateAfterWaitFailure (t * testing.T ) {
778921 restore := overridePublishCommandTestHooks (t )
779922 defer restore ()
0 commit comments