@@ -530,6 +530,131 @@ func TestDeleteAccount_PendingZero_DeletesNormally(t *testing.T) {
530530 assert .True (t , deleteCalled , "delete should proceed when no pending executions" )
531531}
532532
533+ // TestDeleteAccount_ActiveExecutions_Returns409 asserts the issue #265
534+ // preflight: with no cancelable pending/notified rows but an approved,
535+ // running, scheduled or paused row still on the account, the handler refuses
536+ // with a 409 that says why (reason "active_executions" + count) instead of
537+ // letting the FK raise the misleading "cancel pending purchases" 409.
538+ func TestDeleteAccount_ActiveExecutions_Returns409 (t * testing.T ) {
539+ ctx := context .Background ()
540+ mockAuth := new (MockAuthService )
541+ setupAdminAuth (ctx , mockAuth )
542+
543+ store := setupAdminMock (ctx )
544+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) { return 0 , nil }
545+ var liveAccount string
546+ store .CountLiveExecutionsForAccountFn = func (_ context.Context , id string ) (int , error ) {
547+ liveAccount = id
548+ return 2 , nil
549+ }
550+ deleteCalled := false
551+ store .DeleteCloudAccountFn = func (_ context.Context , _ string ) error {
552+ deleteCalled = true
553+ return nil
554+ }
555+ handler := & Handler {auth : mockAuth , config : store }
556+
557+ const id = "11111111-1111-1111-1111-111111111111"
558+ result , err := handler .deleteAccount (ctx , adminRequest ("" ), id )
559+ assert .Nil (t , result )
560+ require .Error (t , err )
561+ ce , ok := IsClientError (err )
562+ require .True (t , ok , "expected ClientError, got %v" , err )
563+ assert .Equal (t , 409 , ce .code )
564+ assert .Contains (t , ce .message , "2 in-progress purchase(s)" )
565+ assert .NotContains (t , ce .message , "canceled first" , "these rows cannot be canceled; the message must not say to" )
566+ details := ce .Details ()
567+ require .NotNil (t , details )
568+ assert .EqualValues (t , 2 , details ["active_count" ])
569+ assert .Equal (t , "active_executions" , details ["reason" ])
570+ assert .Equal (t , id , liveAccount , "the live count must be scoped to the account being deleted" )
571+ assert .False (t , deleteCalled , "DeleteCloudAccount must NOT be called while live executions exist" )
572+ }
573+
574+ // TestDeleteAccount_PendingTakesPrecedenceOverActive asserts the #606
575+ // Cancel-All-Then-Delete 409 still wins when cancelable rows exist, so the
576+ // frontend keeps getting the execution IDs it needs.
577+ func TestDeleteAccount_PendingTakesPrecedenceOverActive (t * testing.T ) {
578+ ctx := context .Background ()
579+ mockAuth := new (MockAuthService )
580+ setupAdminAuth (ctx , mockAuth )
581+
582+ store := setupAdminMock (ctx )
583+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) { return 1 , nil }
584+ store .ListPendingExecutionIDsForAccountFn = func (_ context.Context , _ string ) ([]string , error ) {
585+ return []string {"exec-1" }, nil
586+ }
587+ liveCalled := false
588+ store .CountLiveExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
589+ liveCalled = true
590+ return 4 , nil
591+ }
592+ handler := & Handler {auth : mockAuth , config : store }
593+
594+ _ , err := handler .deleteAccount (ctx , adminRequest ("" ), "11111111-1111-1111-1111-111111111111" )
595+ ce , ok := IsClientError (err )
596+ require .True (t , ok , "expected ClientError, got %v" , err )
597+ assert .Equal (t , 409 , ce .code )
598+ assert .Equal (t , "pending_executions" , ce .Details ()["reason" ])
599+ assert .False (t , liveCalled , "the live count is not needed once pending rows already block" )
600+ }
601+
602+ // TestDeleteAccount_OnlyTerminalExecutions_Deletes asserts the issue #265
603+ // happy path at the handler: no pending and no live rows means the delete
604+ // goes through (DeleteCloudAccount detaches the terminal history itself).
605+ func TestDeleteAccount_OnlyTerminalExecutions_Deletes (t * testing.T ) {
606+ ctx := context .Background ()
607+ mockAuth := new (MockAuthService )
608+ setupAdminAuth (ctx , mockAuth )
609+
610+ store := setupAdminMock (ctx )
611+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) { return 0 , nil }
612+ liveCalled := false
613+ store .CountLiveExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
614+ liveCalled = true
615+ return 0 , nil
616+ }
617+ deleteCalled := false
618+ store .DeleteCloudAccountFn = func (_ context.Context , _ string ) error {
619+ deleteCalled = true
620+ return nil
621+ }
622+ handler := & Handler {auth : mockAuth , config : store }
623+
624+ result , err := handler .deleteAccount (ctx , adminRequest ("" ), "11111111-1111-1111-1111-111111111111" )
625+ require .NoError (t , err )
626+ assert .Nil (t , result )
627+ assert .True (t , liveCalled , "the live preflight must run before the delete" )
628+ assert .True (t , deleteCalled )
629+ }
630+
631+ // TestDeleteAccount_LiveCountErr_Returns500 asserts a failing live count is
632+ // surfaced as an error (500) and never falls through to the delete.
633+ func TestDeleteAccount_LiveCountErr_Returns500 (t * testing.T ) {
634+ ctx := context .Background ()
635+ mockAuth := new (MockAuthService )
636+ setupAdminAuth (ctx , mockAuth )
637+
638+ store := setupAdminMock (ctx )
639+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) { return 0 , nil }
640+ store .CountLiveExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
641+ return 0 , errors .New ("db down" )
642+ }
643+ deleteCalled := false
644+ store .DeleteCloudAccountFn = func (_ context.Context , _ string ) error {
645+ deleteCalled = true
646+ return nil
647+ }
648+ handler := & Handler {auth : mockAuth , config : store }
649+
650+ _ , err := handler .deleteAccount (ctx , adminRequest ("" ), "11111111-1111-1111-1111-111111111111" )
651+ require .Error (t , err )
652+ _ , ok := IsClientError (err )
653+ assert .False (t , ok , "a store failure must not be reported as a client error" )
654+ assert .Contains (t , err .Error (), "db down" )
655+ assert .False (t , deleteCalled )
656+ }
657+
533658// TestDeleteAccount_PendingListErr_StillReturns409 asserts that a failure
534659// listing execution_ids doesn't downgrade the 409 to a 500 — the operator
535660// still gets the structured count and the explicit "cancel first" ask,
@@ -598,6 +723,93 @@ func TestDeleteAccount_FKViolationRace_Returns409(t *testing.T) {
598723 assert .Equal (t , "pending_executions" , details ["reason" ])
599724}
600725
726+ // TestDeleteAccount_FKViolationRace_RequeriesPreflight asserts that after a
727+ // 23503 race the handler re-runs the preflight, so the client gets the
728+ // specific structured 409 for whatever row raced in: pending (with its count)
729+ // or active (approved/running/scheduled/paused), not a generic pending 409.
730+ func TestDeleteAccount_FKViolationRace_RequeriesPreflight (t * testing.T ) {
731+ cases := []struct {
732+ name string
733+ pending int
734+ live int
735+ wantReason string
736+ wantKey string
737+ }{
738+ {"pending row raced in" , 2 , 0 , "pending_executions" , "pending_count" },
739+ {"active row raced in" , 0 , 1 , "active_executions" , "active_count" },
740+ }
741+ for _ , tc := range cases {
742+ t .Run (tc .name , func (t * testing.T ) {
743+ ctx := context .Background ()
744+ mockAuth := new (MockAuthService )
745+ setupAdminAuth (ctx , mockAuth )
746+
747+ store := setupAdminMock (ctx )
748+ pendingCalls , liveCalls := 0 , 0
749+ // First call is the preflight (nothing yet); the second is the
750+ // re-query after the FK violation, which sees the raced-in row.
751+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
752+ pendingCalls ++
753+ if pendingCalls == 1 {
754+ return 0 , nil
755+ }
756+ return tc .pending , nil
757+ }
758+ store .CountLiveExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
759+ liveCalls ++
760+ if liveCalls == 1 {
761+ return 0 , nil
762+ }
763+ return tc .live , nil
764+ }
765+ store .DeleteCloudAccountFn = func (_ context.Context , _ string ) error {
766+ return & pgconn.PgError {Code : "23503" }
767+ }
768+ handler := & Handler {auth : mockAuth , config : store }
769+
770+ _ , err := handler .deleteAccount (ctx , adminRequest ("" ), "11111111-1111-1111-1111-111111111111" )
771+ require .Error (t , err )
772+ ce , ok := IsClientError (err )
773+ require .True (t , ok , "expected ClientError (409), got %T: %v" , err , err )
774+ assert .Equal (t , 409 , ce .code )
775+ details := ce .Details ()
776+ require .NotNil (t , details )
777+ assert .Equal (t , tc .wantReason , details ["reason" ])
778+ assert .Contains (t , details , tc .wantKey , "re-queried 409 carries the count" )
779+ })
780+ }
781+ }
782+
783+ // TestDeleteAccount_FKViolationRace_RequeryErrorFallsBack asserts that if the
784+ // re-query itself fails, the handler still returns the bare pending 409
785+ // rather than surfacing the re-query's 500.
786+ func TestDeleteAccount_FKViolationRace_RequeryErrorFallsBack (t * testing.T ) {
787+ ctx := context .Background ()
788+ mockAuth := new (MockAuthService )
789+ setupAdminAuth (ctx , mockAuth )
790+
791+ store := setupAdminMock (ctx )
792+ calls := 0
793+ store .CountPendingExecutionsForAccountFn = func (_ context.Context , _ string ) (int , error ) {
794+ calls ++
795+ if calls == 1 {
796+ return 0 , nil
797+ }
798+ return 0 , errors .New ("connection reset" )
799+ }
800+ store .DeleteCloudAccountFn = func (_ context.Context , _ string ) error {
801+ return & pgconn.PgError {Code : "23503" }
802+ }
803+ handler := & Handler {auth : mockAuth , config : store }
804+
805+ _ , err := handler .deleteAccount (ctx , adminRequest ("" ), "11111111-1111-1111-1111-111111111111" )
806+ require .Error (t , err )
807+ ce , ok := IsClientError (err )
808+ require .True (t , ok , "expected ClientError (409), got %T: %v" , err , err )
809+ assert .Equal (t , 409 , ce .code )
810+ assert .Equal (t , "pending_executions" , ce .Details ()["reason" ])
811+ }
812+
601813// TestDeleteAccount_NonFKError_StillReturns500 asserts that errors which
602814// are NOT FK violations continue to bubble up as wrapped errors (500),
603815// so we don't accidentally swallow real failures as 409s.
0 commit comments