Http::response('ok', 200), ]); } public function test_index_page_loads_for_new_visitors(): void { $this->get(route('verification.index')) ->assertOk() ->assertViewIs('verification.index'); } public function test_send_otp_starts_verification_flow(): void { $this->post(route('verification.send'), ['phone' => self::PHONE]) ->assertRedirect(route('verification.verify.form')); $this->assertDatabaseHas(PhoneVerification::class, [ 'phone' => self::UNMASKED_PHONE, 'verified' => false, ]); Http::assertSentCount(1); } public function test_send_otp_rejects_duplicate_phone(): void { Coupon::factory()->create(['phone' => self::UNMASKED_PHONE]); $this->from(route('verification.index')) ->post(route('verification.send'), ['phone' => self::PHONE]) ->assertRedirect(route('verification.index')) ->assertSessionHasErrors('phone'); } public function test_send_otp_rejects_invalid_phone_format(): void { $this->from(route('verification.index')) ->post(route('verification.send'), ['phone' => '+993 81 23 45 67']) ->assertRedirect(route('verification.index')) ->assertSessionHasErrors('phone'); } public function test_verify_form_requires_session_phone(): void { $this->get(route('verification.verify.form')) ->assertRedirect(route('verification.index')); } public function test_full_verification_flow_issues_coupon(): void { $this->post(route('verification.send'), ['phone' => self::PHONE]) ->assertRedirect(route('verification.verify.form')); $otp = $this->extractOtpFromLastSms(); $this->get(route('verification.verify.form'))->assertOk(); $this->post(route('verification.verify'), ['otp' => $otp]) ->assertRedirect(route('verification.congratulations')) ->assertCookie('device_registered'); $coupon = Coupon::query()->where('phone', self::UNMASKED_PHONE)->first(); $this->assertNotNull($coupon); $this->assertContains( $coupon->code, (new CouponCodePool)->allCodes(), ); $this->get(route('verification.congratulations')) ->assertOk() ->assertViewHas('code'); } public function test_index_shows_promotion_ended_when_pool_exhausted(): void { $this->mock(CouponCodePool::class, function (MockInterface $mock): void { $mock->shouldReceive('hasAvailable')->andReturn(false); }); $this->get(route('verification.index')) ->assertOk() ->assertViewIs('verification.promotion-ended') ->assertSee(CouponPoolExhaustedException::MESSAGE); } public function test_send_otp_rejects_when_pool_exhausted(): void { $this->mock(CouponCodePool::class, function (MockInterface $mock): void { $mock->shouldReceive('hasAvailable')->andReturn(false); }); $this->from(route('verification.index')) ->post(route('verification.send'), ['phone' => self::PHONE]) ->assertRedirect(route('verification.index')) ->assertSessionHasErrors(['phone' => CouponPoolExhaustedException::MESSAGE]); } public function test_verify_otp_rejects_wrong_code(): void { PhoneVerification::factory() ->withOtp('1234') ->create(['phone' => self::UNMASKED_PHONE]); $this->withSession(['verify_phone' => self::UNMASKED_PHONE]) ->from(route('verification.verify.form')) ->post(route('verification.verify'), ['otp' => '9999']) ->assertRedirect(route('verification.verify.form')) ->assertSessionHasErrors('otp'); } public function test_verify_otp_rejects_expired_code(): void { PhoneVerification::factory() ->withOtp('1234') ->expired() ->create(['phone' => self::UNMASKED_PHONE]); $this->withSession(['verify_phone' => self::UNMASKED_PHONE]) ->from(route('verification.verify.form')) ->post(route('verification.verify'), ['otp' => '1234']) ->assertRedirect(route('verification.verify.form')) ->assertSessionHasErrors(['otp' => 'Kodyň möhleti gutardy.']); } public function test_verify_otp_requires_session_phone(): void { $this->post(route('verification.verify'), ['otp' => '1234']) ->assertRedirect(route('verification.index')); } public function test_resend_otp_sends_new_sms(): void { $this->withSession(['verify_phone' => self::UNMASKED_PHONE]) ->from(route('verification.verify.form')) ->post(route('verification.resend')) ->assertRedirect(route('verification.verify.form')) ->assertSessionHas('status'); Http::assertSentCount(1); } public function test_congratulations_recovers_coupon_from_device_cookie(): void { $coupon = Coupon::factory()->create(['phone' => self::UNMASKED_PHONE]); $this->withCookie('device_registered', encrypt(self::UNMASKED_PHONE)) ->get(route('verification.congratulations')) ->assertOk() ->assertViewIs('verification.congratulations') ->assertSee($coupon->code); } public function test_congratulations_redirects_when_session_coupon_missing_from_db(): void { $this->withSession(['coupon_code' => '9_STALE']) ->get(route('verification.congratulations')) ->assertRedirect(route('verification.index')) ->assertSessionMissing('coupon_code'); } public function test_send_otp_is_rate_limited(): void { for ($i = 0; $i < 3; $i++) { $this->post(route('verification.send'), ['phone' => self::PHONE]); } $this->post(route('verification.send'), ['phone' => self::PHONE]) ->assertStatus(429); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } private function extractOtpFromLastSms(): string { $recorded = Http::recorded()->all(); $this->assertNotEmpty($recorded); [$request] = $recorded[array_key_last($recorded)]; preg_match('/(\d{4})/', (string) $request->data()['message'], $matches); $this->assertNotEmpty($matches[1]); return $matches[1]; } }