| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144 |
- package service
- import (
- "context"
- "sync"
- "testing"
- "time"
- "github.com/stretchr/testify/require"
- )
- // fakeLeaderLockCache is an in-memory LeaderLockCache for unit tests. It models the
- // compare-and-delete release semantics of the real Redis-backed implementation.
- type fakeLeaderLockCache struct {
- mu sync.Mutex
- owners map[string]string
- acquireErr error
- }
- func (f *fakeLeaderLockCache) TryAcquireLeaderLock(_ context.Context, key, owner string, _ time.Duration) (bool, error) {
- if f.acquireErr != nil {
- return false, f.acquireErr
- }
- f.mu.Lock()
- defer f.mu.Unlock()
- if f.owners == nil {
- f.owners = map[string]string{}
- }
- if _, held := f.owners[key]; held {
- return false, nil
- }
- f.owners[key] = owner
- return true, nil
- }
- func (f *fakeLeaderLockCache) ReleaseLeaderLock(_ context.Context, key, owner string) error {
- f.mu.Lock()
- defer f.mu.Unlock()
- if f.owners[key] == owner {
- delete(f.owners, key)
- }
- return nil
- }
- func (f *fakeLeaderLockCache) heldBy(key string) string {
- f.mu.Lock()
- defer f.mu.Unlock()
- return f.owners[key]
- }
- func TestTryAcquireSingletonLeaderLock_NoBackendRunsUngated(t *testing.T) {
- release, ok := tryAcquireSingletonLeaderLock(context.Background(), nil, nil, "k", "inst", time.Minute)
- require.True(t, ok)
- require.NotNil(t, release)
- require.NotPanics(t, release)
- }
- func TestTryAcquireSingletonLeaderLock_ContendedThenReleased(t *testing.T) {
- cache := &fakeLeaderLockCache{}
- ctx := context.Background()
- const key = "leader:test:contended"
- releaseA, ok := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "A", time.Minute)
- require.True(t, ok, "first instance should acquire")
- require.Equal(t, "A", cache.heldBy(key))
- _, okB := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "B", time.Minute)
- require.False(t, okB, "peer must be locked out while the lock is held")
- releaseA()
- require.Empty(t, cache.heldBy(key), "release must free the lock")
- releaseB, okB := tryAcquireSingletonLeaderLock(ctx, cache, nil, key, "B", time.Minute)
- require.True(t, okB, "peer should acquire after the holder releases")
- releaseB()
- }
- // When the cache errors, the helper must fall through rather than acquire via the
- // cache. With no DB configured it runs ungated so the job is never starved by a
- // flaky Redis.
- func TestTryAcquireSingletonLeaderLock_CacheErrorFallsThrough(t *testing.T) {
- cache := &fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}
- release, ok := tryAcquireSingletonLeaderLock(context.Background(), cache, nil, "k", "inst", time.Minute)
- require.True(t, ok, "cache error with no DB must run ungated, not skip")
- require.NotNil(t, release)
- require.NotPanics(t, release)
- }
- func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) {
- cache := &fakeLeaderLockCache{}
- // A peer already holds the reminder leader lock.
- _, _ = cache.TryAcquireLeaderLock(context.Background(), subscriptionExpiryReminderLeaderLockKey, "peer", time.Minute)
- repo := &subscriptionExpiryRepoStub{}
- settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
- svc := NewSubscriptionExpiryService(repo, time.Minute)
- svc.SetSettingRepository(settingRepo)
- svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
- svc.SetLeaderLock(cache, nil)
- svc.sendExpiryReminders(context.Background())
- require.Zero(t, repo.listCalls, "non-leader must not scan active subscriptions")
- }
- func TestSubscriptionExpiryService_ReminderScansWhenLeader(t *testing.T) {
- repo := &subscriptionExpiryRepoStub{}
- settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
- svc := NewSubscriptionExpiryService(repo, time.Minute)
- svc.SetSettingRepository(settingRepo)
- svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
- svc.SetLeaderLock(&fakeLeaderLockCache{}, nil)
- svc.sendExpiryReminders(context.Background())
- require.Equal(t, 1, repo.listCalls, "leader should scan active subscriptions once")
- }
- // Single-instance correctness: the lock is released at the end of each cycle, so
- // the same instance must re-acquire it and run on every subsequent cycle (no
- // self-lockout). Covers both the cache-backed path and the no-backend path.
- func TestSubscriptionExpiryService_ReminderRunsEveryCycleSingleInstance(t *testing.T) {
- cases := map[string]LeaderLockCache{
- "with_cache": &fakeLeaderLockCache{},
- "no_backend": nil,
- }
- for name, cache := range cases {
- t.Run(name, func(t *testing.T) {
- repo := &subscriptionExpiryRepoStub{}
- settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}}
- svc := NewSubscriptionExpiryService(repo, time.Minute)
- svc.SetSettingRepository(settingRepo)
- svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil)))
- svc.SetLeaderLock(cache, nil)
- // Three consecutive cycles, mimicking the ticker loop.
- svc.sendExpiryReminders(context.Background())
- svc.sendExpiryReminders(context.Background())
- svc.sendExpiryReminders(context.Background())
- require.Equal(t, 3, repo.listCalls, "single instance must run every cycle")
- })
- }
- }
|