@@ -535,10 +535,93 @@ async fn log_dispatch(
535535 Ok ( ( ) )
536536}
537537
538+ /// A compact representation of the batch state used to make status decisions.
539+ ///
540+ /// The worker persists these same transitions in `claim_next_batch` and
541+ /// `finalize_batch`. Keeping the decision rule pure makes it possible to test
542+ /// the state machine without requiring a live PostgreSQL instance.
543+ #[ derive( Debug , PartialEq , Eq ) ]
544+ struct BatchState {
545+ status : & ' static str ,
546+ pending_recipients : usize ,
547+ failed_recipients : usize ,
548+ total_recipients : usize ,
549+ }
550+
551+ fn next_batch_status ( state : & BatchState ) -> & ' static str {
552+ match state. status {
553+ "PENDING" => "PROCESSING" ,
554+ "PROCESSING" if state. pending_recipients > 0 => "PROCESSING" ,
555+ "PROCESSING" if state. failed_recipients == 0 => "COMPLETED" ,
556+ "PROCESSING" if state. failed_recipients == state. total_recipients => "FAILED" ,
557+ "PROCESSING" => "PARTIALLY_FAILED" ,
558+ status => status,
559+ }
560+ }
561+
538562#[ cfg( test) ]
539563mod tests {
540564 use super :: * ;
541565
566+ fn sample_batch ( status : & ' static str , pending : usize , failed : usize ) -> BatchState {
567+ BatchState {
568+ status,
569+ pending_recipients : pending,
570+ failed_recipients : failed,
571+ total_recipients : 3 ,
572+ }
573+ }
574+
575+ #[ test]
576+ fn worker_moves_pending_batch_to_processing ( ) {
577+ let batch = sample_batch ( "PENDING" , 3 , 0 ) ;
578+
579+ assert_eq ! ( next_batch_status( & batch) , "PROCESSING" ) ;
580+ }
581+
582+ #[ test]
583+ fn worker_completes_processing_batch_when_all_recipients_are_submitted ( ) {
584+ let batch = sample_batch ( "PROCESSING" , 0 , 0 ) ;
585+
586+ assert_eq ! ( next_batch_status( & batch) , "COMPLETED" ) ;
587+ }
588+
589+ #[ test]
590+ fn worker_recovers_after_retryable_error_and_completes_batch ( ) {
591+ // A retryable SDP error leaves the recipient pending, so the batch must
592+ // remain PROCESSING and be eligible for another worker cycle.
593+ let after_error = sample_batch ( "PROCESSING" , 1 , 0 ) ;
594+ assert_eq ! ( next_batch_status( & after_error) , "PROCESSING" ) ;
595+
596+ // Once the retry succeeds, no pending recipients remain and the batch
597+ // reaches its terminal completed state.
598+ let after_retry = sample_batch ( "PROCESSING" , 0 , 0 ) ;
599+ assert_eq ! ( next_batch_status( & after_retry) , "COMPLETED" ) ;
600+ }
601+
602+ #[ test]
603+ fn worker_marks_all_permanent_failures_as_failed ( ) {
604+ let batch = sample_batch ( "PROCESSING" , 0 , 3 ) ;
605+
606+ assert_eq ! ( next_batch_status( & batch) , "FAILED" ) ;
607+ }
608+
609+ #[ test]
610+ fn worker_marks_mixed_success_and_failure_as_partially_failed ( ) {
611+ let batch = sample_batch ( "PROCESSING" , 0 , 1 ) ;
612+
613+ assert_eq ! ( next_batch_status( & batch) , "PARTIALLY_FAILED" ) ;
614+ }
615+
616+ #[ test]
617+ fn terminal_batch_statuses_are_not_reopened ( ) {
618+ let completed = sample_batch ( "COMPLETED" , 0 , 0 ) ;
619+ let failed = sample_batch ( "FAILED" , 0 , 3 ) ;
620+
621+ assert_eq ! ( next_batch_status( & completed) , "COMPLETED" ) ;
622+ assert_eq ! ( next_batch_status( & failed) , "FAILED" ) ;
623+ }
624+
542625 #[ test]
543626 fn idempotency_key_is_stable_across_retries ( ) {
544627 let id = Uuid :: new_v4 ( ) ;
0 commit comments