|
20 | 20 |
|
21 | 21 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
22 | 22 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 23 | +import static org.junit.jupiter.api.Assertions.assertFalse; |
23 | 24 | import static org.junit.jupiter.api.Assertions.assertThrows; |
24 | 25 | import static org.junit.jupiter.api.Assertions.assertTrue; |
25 | 26 | import static org.mockito.ArgumentMatchers.anyString; |
26 | 27 | import static org.mockito.Mockito.any; |
27 | 28 | import static org.mockito.Mockito.atLeast; |
28 | 29 | import static org.mockito.Mockito.doAnswer; |
29 | 30 | import static org.mockito.Mockito.mock; |
| 31 | +import static org.mockito.Mockito.never; |
30 | 32 | import static org.mockito.Mockito.reset; |
31 | 33 | import static org.mockito.Mockito.times; |
32 | 34 | import static org.mockito.Mockito.verify; |
|
52 | 54 | import org.apache.fineract.infrastructure.core.api.JsonCommand; |
53 | 55 | import org.apache.fineract.infrastructure.core.config.FineractProperties; |
54 | 56 | import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; |
| 57 | +import org.apache.fineract.infrastructure.core.domain.BatchRequestContextHolder; |
55 | 58 | import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; |
56 | 59 | import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException; |
57 | 60 | import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; |
|
67 | 70 | import org.mockito.Spy; |
68 | 71 | import org.springframework.context.ApplicationContext; |
69 | 72 | import org.springframework.lang.NonNull; |
| 73 | +import org.springframework.transaction.support.TransactionSynchronizationManager; |
70 | 74 | import org.springframework.web.context.request.RequestContextHolder; |
71 | 75 | import org.springframework.web.context.request.ServletRequestAttributes; |
72 | 76 |
|
@@ -598,4 +602,103 @@ public void testExecuteCommandWithMaxRetryFailure() { |
598 | 602 | underTest.executeCommand(commandWrapper, jsonCommand, false); |
599 | 603 | verify(commandSourceService, times(4)).getCommandSource(commandId); |
600 | 604 | } |
| 605 | + |
| 606 | + /** |
| 607 | + * Test that when running inside an enclosing batch transaction, hook events are NOT published immediately but |
| 608 | + * deferred to afterCommit. This prevents webhooks (e.g. SMS notifications) from firing for commands that are |
| 609 | + * subsequently rolled back when a later command in the batch fails. |
| 610 | + */ |
| 611 | + @Test |
| 612 | + public void testHookEventDeferredInEnclosingTransaction() { |
| 613 | + CommandWrapper commandWrapper = getCommandWrapper(); |
| 614 | + |
| 615 | + long commandId = 1L; |
| 616 | + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); |
| 617 | + when(jsonCommand.commandId()).thenReturn(commandId); |
| 618 | + |
| 619 | + NewCommandSourceHandler commandHandler = Mockito.mock(NewCommandSourceHandler.class); |
| 620 | + CommandProcessingResult commandProcessingResult = Mockito.mock(CommandProcessingResult.class); |
| 621 | + when(commandProcessingResult.isRollbackTransaction()).thenReturn(false); |
| 622 | + when(commandHandler.processCommand(jsonCommand)).thenReturn(commandProcessingResult); |
| 623 | + when(commandHandlerProvider.getHandler(Mockito.any(), Mockito.any())).thenReturn(commandHandler); |
| 624 | + |
| 625 | + when(configurationDomainService.isMakerCheckerEnabledForTask(Mockito.any())).thenReturn(false); |
| 626 | + String idk = "idk"; |
| 627 | + when(idempotencyKeyResolver.resolve(commandWrapper)).thenReturn(idk); |
| 628 | + CommandSource commandSource = Mockito.mock(CommandSource.class); |
| 629 | + when(commandSource.getId()).thenReturn(commandId); |
| 630 | + when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(null); |
| 631 | + when(commandSourceService.getCommandSource(commandId)).thenReturn(commandSource); |
| 632 | + |
| 633 | + AppUser appUser = Mockito.mock(AppUser.class); |
| 634 | + when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser); |
| 635 | + when(commandSourceService.saveInitialNewTransaction(commandWrapper, jsonCommand, appUser, idk)).thenReturn(commandSource); |
| 636 | + when(commandSourceService.saveResultSameTransaction(commandSource)).thenReturn(commandSource); |
| 637 | + when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue()); |
| 638 | + |
| 639 | + when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false)) |
| 640 | + .thenReturn(commandProcessingResult); |
| 641 | + |
| 642 | + // Simulate enclosing batch transaction with active synchronization |
| 643 | + BatchRequestContextHolder.setIsEnclosingTransaction(true); |
| 644 | + TransactionSynchronizationManager.initSynchronization(); |
| 645 | + try { |
| 646 | + underTest.executeCommand(commandWrapper, jsonCommand, false); |
| 647 | + |
| 648 | + // Hook event should NOT have been published immediately |
| 649 | + verify(applicationContext, never()).publishEvent(any()); |
| 650 | + |
| 651 | + // It should be registered as an afterCommit synchronization |
| 652 | + assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size()); |
| 653 | + } finally { |
| 654 | + TransactionSynchronizationManager.clearSynchronization(); |
| 655 | + BatchRequestContextHolder.resetIsEnclosingTransaction(); |
| 656 | + } |
| 657 | + } |
| 658 | + |
| 659 | + /** |
| 660 | + * Test that when NOT in an enclosing transaction, no TransactionSynchronization is registered. The code takes the |
| 661 | + * immediate path (not the deferred path), preserving existing behaviour for non-batch single-request commands. |
| 662 | + */ |
| 663 | + @Test |
| 664 | + public void testHookEventNotDeferredOutsideEnclosingTransaction() { |
| 665 | + CommandWrapper commandWrapper = getCommandWrapper(); |
| 666 | + |
| 667 | + long commandId = 1L; |
| 668 | + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); |
| 669 | + when(jsonCommand.commandId()).thenReturn(commandId); |
| 670 | + when(jsonCommand.json()).thenReturn(null); // null causes publishHookEvent to exit early; avoids mocking the |
| 671 | + // full hook-serialisation chain |
| 672 | + |
| 673 | + NewCommandSourceHandler commandHandler = Mockito.mock(NewCommandSourceHandler.class); |
| 674 | + CommandProcessingResult commandProcessingResult = Mockito.mock(CommandProcessingResult.class); |
| 675 | + when(commandProcessingResult.isRollbackTransaction()).thenReturn(false); |
| 676 | + when(commandHandler.processCommand(jsonCommand)).thenReturn(commandProcessingResult); |
| 677 | + when(commandHandlerProvider.getHandler(Mockito.any(), Mockito.any())).thenReturn(commandHandler); |
| 678 | + |
| 679 | + when(configurationDomainService.isMakerCheckerEnabledForTask(Mockito.any())).thenReturn(false); |
| 680 | + String idk = "idk"; |
| 681 | + when(idempotencyKeyResolver.resolve(commandWrapper)).thenReturn(idk); |
| 682 | + CommandSource commandSource = Mockito.mock(CommandSource.class); |
| 683 | + when(commandSource.getId()).thenReturn(commandId); |
| 684 | + when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(null); |
| 685 | + when(commandSourceService.getCommandSource(commandId)).thenReturn(commandSource); |
| 686 | + |
| 687 | + AppUser appUser = Mockito.mock(AppUser.class); |
| 688 | + when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser); |
| 689 | + when(commandSourceService.saveInitialNewTransaction(commandWrapper, jsonCommand, appUser, idk)).thenReturn(commandSource); |
| 690 | + when(commandSourceService.saveResultSameTransaction(commandSource)).thenReturn(commandSource); |
| 691 | + when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue()); |
| 692 | + |
| 693 | + when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false)) |
| 694 | + .thenReturn(commandProcessingResult); |
| 695 | + |
| 696 | + // Not in enclosing transaction (default) |
| 697 | + BatchRequestContextHolder.resetIsEnclosingTransaction(); |
| 698 | + |
| 699 | + underTest.executeCommand(commandWrapper, jsonCommand, false); |
| 700 | + |
| 701 | + // The deferred path was not taken: no TransactionSynchronization should have been registered. |
| 702 | + assertFalse(TransactionSynchronizationManager.isSynchronizationActive()); |
| 703 | + } |
601 | 704 | } |
0 commit comments