@@ -1745,6 +1745,205 @@ func TestBroadcastOrchestrator_Process_AutoWinnerEvaluationPath(t *testing.T) {
17451745 assert .True (t , done )
17461746}
17471747
1748+ // TestBroadcastOrchestrator_Process_ABTestWinnerPhaseProcessesRemainingRecipients
1749+ // This test reproduces and verifies the fix for the bug where the winner phase
1750+ // would not process remaining recipients after test phase completion.
1751+ // Scenario: 2 recipients, 50% test phase (1 recipient), winner selection, then winner phase should process remaining 1 recipient.
1752+ func TestBroadcastOrchestrator_Process_ABTestWinnerPhaseProcessesRemainingRecipients (t * testing.T ) {
1753+ ctrl := gomock .NewController (t )
1754+ defer ctrl .Finish ()
1755+
1756+ // Create mocks
1757+ mockMessageSender := mocks .NewMockMessageSender (ctrl )
1758+ mockBroadcastRepo := domainmocks .NewMockBroadcastRepository (ctrl )
1759+ mockTemplateRepo := domainmocks .NewMockTemplateRepository (ctrl )
1760+ mockContactRepo := domainmocks .NewMockContactRepository (ctrl )
1761+ mockTaskRepo := domainmocks .NewMockTaskRepository (ctrl )
1762+ mockWorkspaceRepo := domainmocks .NewMockWorkspaceRepository (ctrl )
1763+ mockLogger := pkgmocks .NewMockLogger (ctrl )
1764+ mockTimeProvider := mocks .NewMockTimeProvider (ctrl )
1765+
1766+ // Mock time provider
1767+ base := time .Date (2023 , 1 , 1 , 12 , 0 , 0 , 0 , time .UTC )
1768+ mockTimeProvider .EXPECT ().Now ().Return (base ).AnyTimes ()
1769+ mockTimeProvider .EXPECT ().Since (gomock .Any ()).Return (time .Minute ).AnyTimes ()
1770+
1771+ // Setup workspace with email provider
1772+ workspace := & domain.Workspace {
1773+ ID : "workspace-123" ,
1774+ Settings : domain.WorkspaceSettings {
1775+ SecretKey : "secret-key" ,
1776+ EmailTrackingEnabled : true ,
1777+ MarketingEmailProviderID : "marketing-provider-id" ,
1778+ },
1779+ Integrations : []domain.Integration {
1780+ {
1781+ ID : "marketing-provider-id" ,
1782+ Type : domain .IntegrationTypeEmail ,
1783+ EmailProvider : domain.EmailProvider {
1784+ Kind : domain .EmailProviderKindSES ,
1785+ SES : & domain.AmazonSESSettings {
1786+ AccessKey : "access-key" ,
1787+ SecretKey : "secret-key" ,
1788+ Region : "us-east-1" ,
1789+ },
1790+ },
1791+ },
1792+ },
1793+ }
1794+ mockWorkspaceRepo .EXPECT ().GetByID (gomock .Any (), "workspace-123" ).Return (workspace , nil ).AnyTimes ()
1795+
1796+ // Setup broadcast with A/B testing enabled
1797+ bcast := & domain.Broadcast {
1798+ ID : "broadcast-123" ,
1799+ WorkspaceID : "workspace-123" ,
1800+ Audience : domain.AudienceSettings {Lists : []string {"list-1" }},
1801+ Status : domain .BroadcastStatusWinnerSelected , // Winner already selected
1802+ WinningTemplate : "template-B" , // Winner is template B
1803+ TestSettings : domain.BroadcastTestSettings {
1804+ Enabled : true ,
1805+ SamplePercentage : 50 , // 50% test phase
1806+ Variations : []domain.BroadcastVariation {
1807+ {TemplateID : "template-A" },
1808+ {TemplateID : "template-B" },
1809+ },
1810+ },
1811+ }
1812+ mockBroadcastRepo .EXPECT ().GetBroadcast (gomock .Any (), "workspace-123" , "broadcast-123" ).Return (bcast , nil ).AnyTimes ()
1813+
1814+ // Setup templates - we need to mock both in case the phase transition doesn't work initially
1815+ templateA := & domain.Template {
1816+ ID : "template-A" ,
1817+ Email : & domain.EmailTemplate {
1818+ Subject : "Test Subject A" ,
1819+ SenderID : "sender-1" ,
1820+ VisualEditorTree : & notifuse_mjml.MJMLBlock {
1821+ BaseBlock : notifuse_mjml.BaseBlock {
1822+ ID : "root" ,
1823+ Type : notifuse_mjml .MJMLComponentMjml ,
1824+ },
1825+ },
1826+ },
1827+ }
1828+ templateB := & domain.Template {
1829+ ID : "template-B" ,
1830+ Email : & domain.EmailTemplate {
1831+ Subject : "Test Subject B" ,
1832+ SenderID : "sender-1" ,
1833+ VisualEditorTree : & notifuse_mjml.MJMLBlock {
1834+ BaseBlock : notifuse_mjml.BaseBlock {
1835+ ID : "root" ,
1836+ Type : notifuse_mjml .MJMLComponentMjml ,
1837+ },
1838+ },
1839+ },
1840+ }
1841+
1842+ // Mock template loading - might load all variations first, then just winner
1843+ mockTemplateRepo .EXPECT ().GetTemplateByID (gomock .Any (), "workspace-123" , "template-A" , int64 (0 )).Return (templateA , nil ).AnyTimes ()
1844+ mockTemplateRepo .EXPECT ().GetTemplateByID (gomock .Any (), "workspace-123" , "template-B" , int64 (0 )).Return (templateB , nil ).AnyTimes ()
1845+
1846+ // Setup recipients: winner phase should fetch from offset=1 (after test phase processed 1 recipient)
1847+ // This is the key part of the test - ensuring the winner phase processes the remaining recipient
1848+ recipient := & domain.ContactWithList {
1849+ Contact : & domain.Contact {
1850+ Email : "recipient2@example.com" ,
1851+ },
1852+ ListID : "list-1" ,
1853+ }
1854+ mockContactRepo .EXPECT ().GetContactsForBroadcast (
1855+ gomock .Any (),
1856+ "workspace-123" ,
1857+ bcast .Audience ,
1858+ 1 , // limit: remaining recipients in phase
1859+ 1 , // offset: should be 1 (after test phase processed first recipient)
1860+ ).Return ([]* domain.ContactWithList {recipient }, nil )
1861+
1862+ // Mock successful sending
1863+ mockMessageSender .EXPECT ().SendBatch (
1864+ gomock .Any (),
1865+ "workspace-123" ,
1866+ "secret-key" ,
1867+ true ,
1868+ "broadcast-123" ,
1869+ []* domain.ContactWithList {recipient },
1870+ gomock .Any (), // templates
1871+ gomock .Any (), // email provider
1872+ gomock .Any (), // timeout
1873+ ).Return (1 , 0 , nil ) // 1 sent, 0 failed
1874+
1875+ // Mock task state saving
1876+ mockTaskRepo .EXPECT ().SaveState (gomock .Any (), "workspace-123" , "task-123" , gomock .Any (), gomock .Any ()).Return (nil ).AnyTimes ()
1877+
1878+ // Mock final broadcast status update to "sent"
1879+ mockBroadcastRepo .EXPECT ().UpdateBroadcast (gomock .Any (), gomock .Any ()).DoAndReturn (func (_ context.Context , b * domain.Broadcast ) error {
1880+ assert .Equal (t , domain .BroadcastStatusSent , b .Status )
1881+ return nil
1882+ })
1883+
1884+ // Mock logger calls
1885+ mockLogger .EXPECT ().WithField (gomock .Any (), gomock .Any ()).Return (mockLogger ).AnyTimes ()
1886+ mockLogger .EXPECT ().WithFields (gomock .Any ()).Return (mockLogger ).AnyTimes ()
1887+ mockLogger .EXPECT ().Info (gomock .Any ()).AnyTimes ()
1888+ mockLogger .EXPECT ().Debug (gomock .Any ()).AnyTimes ()
1889+ mockLogger .EXPECT ().Error (gomock .Any ()).AnyTimes ()
1890+
1891+ // Create orchestrator
1892+ config := & broadcast.Config {
1893+ FetchBatchSize : 100 ,
1894+ MaxProcessTime : 30 * time .Second ,
1895+ ProgressLogInterval : 5 * time .Second ,
1896+ }
1897+ orchestrator := broadcast .NewBroadcastOrchestrator (
1898+ mockMessageSender ,
1899+ mockBroadcastRepo ,
1900+ mockTemplateRepo ,
1901+ mockContactRepo ,
1902+ mockTaskRepo ,
1903+ mockWorkspaceRepo ,
1904+ nil , // abTestEvaluator not needed for this test
1905+ mockLogger ,
1906+ config ,
1907+ mockTimeProvider ,
1908+ )
1909+
1910+ // Create task that simulates resuming after test phase completion
1911+ // This is the key: RecipientOffset=1 means test phase already processed 1 recipient
1912+ // Phase="test" but winner is already selected, so should transition to "winner"
1913+ task := & domain.Task {
1914+ ID : "task-123" ,
1915+ WorkspaceID : "workspace-123" ,
1916+ Type : "send_broadcast" ,
1917+ BroadcastID : stringPtr ("broadcast-123" ),
1918+ State : & domain.TaskState {
1919+ SendBroadcast : & domain.SendBroadcastState {
1920+ BroadcastID : "broadcast-123" ,
1921+ TotalRecipients : 2 , // Total of 2 recipients
1922+ TestPhaseRecipientCount : 1 , // Test phase processes 1 recipient (50% of 2)
1923+ WinnerPhaseRecipientCount : 1 , // Winner phase should process remaining 1 recipient
1924+ RecipientOffset : 1 , // Test phase already processed 1 recipient
1925+ SentCount : 1 , // Test phase sent to 1 recipient
1926+ FailedCount : 0 ,
1927+ Phase : "test" , // Should transition to "winner" when processing starts
1928+ TestPhaseCompleted : true ,
1929+ },
1930+ },
1931+ }
1932+
1933+ // Process the task
1934+ ctx := context .Background ()
1935+ timeout := time .Now ().Add (30 * time .Second )
1936+ done , err := orchestrator .Process (ctx , task , timeout )
1937+
1938+ // Verify results
1939+ require .NoError (t , err )
1940+ assert .True (t , done , "Task should be marked as done" )
1941+ assert .Equal (t , "winner" , task .State .SendBroadcast .Phase , "Phase should be 'winner'" )
1942+ assert .Equal (t , int64 (2 ), task .State .SendBroadcast .RecipientOffset , "Should have processed 2 recipients total" )
1943+ assert .Equal (t , 2 , task .State .SendBroadcast .SentCount , "Should have sent to 2 recipients total" )
1944+ assert .Equal (t , 100.0 , task .Progress , "Task progress should be 100%" )
1945+ }
1946+
17481947// Helper function to create string pointer
17491948func stringPtr (s string ) * string {
17501949 return & s
0 commit comments