package sendemailcode import ( "context" "testing" "time" "galaxy/authsession/internal/domain/challenge" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/testkit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestExecuteCreatesThrottledChallengeWithoutUserDirectoryOrMail(t *testing.T) { t.Parallel() challengeStore := &testkit.InMemoryChallengeStore{} abuseProtector := &testkit.InMemorySendEmailCodeAbuseProtector{} now := time.Unix(10, 0).UTC() require.NoError(t, reserveSendCooldown(abuseProtector, common.Email("pilot@example.com"), now)) userDirectory := &countingUserDirectory{} mailSender := &testkit.RecordingMailSender{} service, err := NewWithRuntime( challengeStore, userDirectory, &testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}}, testkit.FixedCodeGenerator{Code: "654321"}, testkit.DeterministicCodeHasher{}, mailSender, abuseProtector, testkit.FixedClock{Time: now}, nil, ) require.NoError(t, err) result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"}) require.NoError(t, err) assert.Equal(t, "challenge-1", result.ChallengeID) assert.Zero(t, userDirectory.resolveCalls) assert.Empty(t, mailSender.RecordedInputs()) record, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1")) require.NoError(t, getErr) assert.Equal(t, challenge.StatusDeliveryThrottled, record.Status) assert.Equal(t, challenge.DeliveryThrottled, record.DeliveryState) assert.Equal(t, 1, record.Attempts.Send) } func TestExecuteBlockedEmailOutsideThrottleStillSuppressesDelivery(t *testing.T) { t.Parallel() challengeStore := &testkit.InMemoryChallengeStore{} userDirectory := &testkit.InMemoryUserDirectory{} require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block"))) mailSender := &testkit.RecordingMailSender{} service, err := NewWithRuntime( challengeStore, userDirectory, &testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}}, testkit.FixedCodeGenerator{Code: "654321"}, testkit.DeterministicCodeHasher{}, mailSender, &testkit.InMemorySendEmailCodeAbuseProtector{}, testkit.FixedClock{Time: time.Unix(10, 0).UTC()}, nil, ) require.NoError(t, err) result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"}) require.NoError(t, err) assert.Equal(t, "challenge-1", result.ChallengeID) assert.Empty(t, mailSender.RecordedInputs()) record, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1")) require.NoError(t, getErr) assert.Equal(t, challenge.StatusDeliverySuppressed, record.Status) assert.Equal(t, challenge.DeliverySuppressed, record.DeliveryState) } func TestExecuteAllowsAgainAfterCooldown(t *testing.T) { t.Parallel() challengeStore := &testkit.InMemoryChallengeStore{} userDirectory := &testkit.InMemoryUserDirectory{} mailSender := &testkit.RecordingMailSender{} abuseProtector := &testkit.InMemorySendEmailCodeAbuseProtector{} clock := &mutableClock{time: time.Unix(10, 0).UTC()} idGenerator := &testkit.SequenceIDGenerator{ ChallengeIDs: []common.ChallengeID{"challenge-1", "challenge-2"}, } service, err := NewWithRuntime( challengeStore, userDirectory, idGenerator, testkit.FixedCodeGenerator{Code: "654321"}, testkit.DeterministicCodeHasher{}, mailSender, abuseProtector, clock, nil, ) require.NoError(t, err) first, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"}) require.NoError(t, err) assert.Equal(t, "challenge-1", first.ChallengeID) clock.time = clock.time.Add(challenge.ResendThrottleCooldown) second, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"}) require.NoError(t, err) assert.Equal(t, "challenge-2", second.ChallengeID) require.Len(t, mailSender.RecordedInputs(), 2) secondRecord, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-2")) require.NoError(t, getErr) assert.Equal(t, challenge.StatusSent, secondRecord.Status) assert.Equal(t, challenge.DeliverySent, secondRecord.DeliveryState) } func reserveSendCooldown(protector ports.SendEmailCodeAbuseProtector, email common.Email, now time.Time) error { _, err := protector.CheckAndReserve(context.Background(), ports.SendEmailCodeAbuseInput{ Email: email, Now: now, }) return err } type mutableClock struct { time time.Time } func (c *mutableClock) Now() time.Time { return c.time } type countingUserDirectory struct { resolveCalls int } func (d *countingUserDirectory) ResolveByEmail(_ context.Context, _ common.Email) (userresolution.Result, error) { d.resolveCalls++ return userresolution.Result{Kind: userresolution.KindCreatable}, nil } func (d *countingUserDirectory) ExistsByUserID(context.Context, common.UserID) (bool, error) { return false, nil } func (d *countingUserDirectory) EnsureUserByEmail(context.Context, ports.EnsureUserInput) (ports.EnsureUserResult, error) { return ports.EnsureUserResult{}, nil } func (d *countingUserDirectory) BlockByUserID(context.Context, ports.BlockUserByIDInput) (ports.BlockUserResult, error) { return ports.BlockUserResult{}, nil } func (d *countingUserDirectory) BlockByEmail(context.Context, ports.BlockUserByEmailInput) (ports.BlockUserResult, error) { return ports.BlockUserResult{}, nil }