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