package service import ( "context" "fmt" "sync" "testing" "time" "github.com/google/uuid" "github.com/igodwin/notifier/internal/domain" ) // TestConcurrentSendGetListNoRace hammers Send, GetNotification, and // ListNotifications concurrently while worker goroutines process notifications // pulled from the queue in the background. It is meant to be run with // `go test -race`: if the service ever stored or returned a raw pointer that a // worker also mutates (the bug this test guards against), the race detector // flags a data race here. func TestConcurrentSendGetListNoRace(t *testing.T) { svc := createTestService(t) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() if err := svc.Start(ctx); err != nil { t.Fatalf("Failed to start service: %v", err) } defer func() { _ = svc.Stop() }() const numSenders = 8 const sendsPerSender = 25 var idsMu sync.Mutex var ids []string var sendersWg sync.WaitGroup for s := 0; s < numSenders; s++ { sendersWg.Add(1) go func(sender int) { defer sendersWg.Done() for i := 0; i < sendsPerSender; i++ { notification := &domain.Notification{ ID: uuid.New().String(), Type: domain.TypeStdout, Status: domain.StatusPending, Subject: fmt.Sprintf("race-test-%d-%d", sender, i), Body: "race test body", Recipients: []string{"race@example.com"}, CC: []string{"cc@example.com"}, Metadata: map[string]interface{}{"sender": sender}, CreatedAt: time.Now(), MaxRetries: 1, } if _, err := svc.Send(ctx, notification); err != nil { t.Errorf("Send failed: %v", err) continue } idsMu.Lock() ids = append(ids, notification.ID) idsMu.Unlock() } }(s) } // Readers race against the senders and against the worker pool mutating // notifications as they're processed. stopReaders := make(chan struct{}) var readersWg sync.WaitGroup for r := 0; r < 4; r++ { readersWg.Add(1) go func() { defer readersWg.Done() for { select { case <-stopReaders: return default: } idsMu.Lock() n := len(ids) var id string if n > 0 { id = ids[n-1] } idsMu.Unlock() if id != "" { if notif, err := svc.GetNotification(ctx, id); err == nil { // Touch the returned notification's reference fields; // if it were aliased with the stored/queued copy a // concurrent worker mutation would trip the race // detector right here. _ = notif.Status _ = append([]string(nil), notif.Recipients...) } } list, err := svc.ListNotifications(ctx, &domain.NotificationFilter{}) if err != nil { t.Errorf("ListNotifications failed: %v", err) continue } for _, notif := range list { _ = notif.Status _ = append([]string(nil), notif.Recipients...) } } }() } sendersWg.Wait() close(stopReaders) readersWg.Wait() // Give the worker pool a moment to drain the queue, then sanity check the // service is still consistent. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { stats, err := svc.GetStats(ctx) if err != nil { t.Fatalf("GetStats failed: %v", err) } if stats.TotalSent == numSenders*sendsPerSender { return } time.Sleep(20 * time.Millisecond) } stats, err := svc.GetStats(ctx) if err != nil { t.Fatalf("GetStats failed: %v", err) } t.Logf("final stats: sent=%d failed=%d queued=%d pending=%d", stats.TotalSent, stats.TotalFailed, stats.TotalQueued, stats.TotalPending) if stats.TotalSent != numSenders*sendsPerSender { t.Errorf("expected all %d notifications to reach Sent, got %d", numSenders*sendsPerSender, stats.TotalSent) } }