diff --git a/app/Exceptions/CouponPoolExhaustedException.php b/app/Exceptions/CouponPoolExhaustedException.php new file mode 100644 index 0000000..c337296 --- /dev/null +++ b/app/Exceptions/CouponPoolExhaustedException.php @@ -0,0 +1,15 @@ +couponCodePool->hasAvailable()) { + return view('verification.promotion-ended'); + } + return view('verification.index'); } @@ -44,6 +51,10 @@ class VerificationController extends Controller return back()->withErrors(['phone' => 'Bu telefon belgisi eýýäm ulanyldy.'])->withInput(); } + if (! $this->couponCodePool->hasAvailable()) { + return back()->withErrors(['phone' => CouponPoolExhaustedException::MESSAGE])->withInput(); + } + if (! $this->otpService->issue($phone)) { return back()->withErrors(['phone' => 'SMS iberilmedi. Soňrak synanyşyň.'])->withInput(); } @@ -63,6 +74,12 @@ class VerificationController extends Controller return redirect()->route('verification.index'); } + $phone = session()->get('verify_phone'); + + if (! $this->couponCodePool->hasAvailable() && ! Coupon::query()->where('phone', $phone)->exists()) { + return redirect()->route('verification.index'); + } + return view('verification.verify', [ 'phone' => session()->get('verify_phone'), ]); @@ -84,6 +101,10 @@ class VerificationController extends Controller return redirect()->route('verification.congratulations'); } + if (! $this->couponCodePool->hasAvailable()) { + return redirect()->route('verification.index'); + } + if (! $this->otpService->issue($phone)) { return back()->withErrors(['otp' => 'SMS iberilmedi. Soňrak synanyşyň.']); } @@ -109,7 +130,11 @@ class VerificationController extends Controller return back()->withErrors(['otp' => 'Nädogry kod.']); } - $coupon = $this->couponService->findOrCreateForPhone($phone); + try { + $coupon = $this->couponService->findOrCreateForPhone($phone); + } catch (CouponPoolExhaustedException) { + return back()->withErrors(['otp' => CouponPoolExhaustedException::MESSAGE]); + } session()->put('coupon_code', $coupon->code); diff --git a/app/Services/CouponCodePool.php b/app/Services/CouponCodePool.php new file mode 100644 index 0000000..472aa77 --- /dev/null +++ b/app/Services/CouponCodePool.php @@ -0,0 +1,77 @@ +|null */ + private ?array $allCodesCache = null; + + public function __construct( + private ?string $codesPath = null, + ) {} + + /** + * @return list + */ + public function allCodes(): array + { + if ($this->allCodesCache !== null) { + return $this->allCodesCache; + } + + $path = $this->codesPath ?? resource_path('codes/codes.txt'); + + if (! is_readable($path)) { + throw new \RuntimeException("Coupon codes file is not readable: {$path}"); + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + if ($lines === false) { + throw new \RuntimeException("Unable to read coupon codes file: {$path}"); + } + + $codes = array_values(array_filter(array_map(trim(...), $lines))); + + if ($codes === []) { + throw new \RuntimeException("Coupon codes file is empty: {$path}"); + } + + return $this->allCodesCache = $codes; + } + + /** + * @return list + */ + public function usedCodes(): array + { + return Coupon::query()->pluck('code')->all(); + } + + /** + * @return list + */ + public function availableCodes(): array + { + return array_values(array_diff($this->allCodes(), $this->usedCodes())); + } + + public function hasAvailable(): bool + { + return $this->availableCodes() !== []; + } + + public function pickRandom(): ?string + { + $available = $this->availableCodes(); + + if ($available === []) { + return null; + } + + return $available[array_rand($available)]; + } +} diff --git a/app/Services/CouponService.php b/app/Services/CouponService.php index b22c243..b116f02 100644 --- a/app/Services/CouponService.php +++ b/app/Services/CouponService.php @@ -2,12 +2,16 @@ namespace App\Services; +use App\Exceptions\CouponPoolExhaustedException; use App\Models\Coupon; use Illuminate\Database\UniqueConstraintViolationException; -use Illuminate\Support\Str; class CouponService { + public function __construct( + private CouponCodePool $couponCodePool, + ) {} + public function findOrCreateForPhone(string $phone): Coupon { $existing = Coupon::query()->where('phone', $phone)->first(); @@ -22,24 +26,22 @@ class CouponService private function createWithUniqueCode(string $phone): Coupon { for ($attempt = 0; $attempt < 10; $attempt++) { + $code = $this->couponCodePool->pickRandom(); + + if ($code === null) { + throw new CouponPoolExhaustedException; + } + try { return Coupon::query()->create([ 'phone' => $phone, - 'code' => $this->generateCouponCode(), + 'code' => $code, ]); } catch (UniqueConstraintViolationException) { continue; } } - throw new \RuntimeException('Unable to generate a unique coupon code.'); - } - - private function generateCouponCode(): string - { - $x = random_int(1, 9); - $yyyy = Str::upper(Str::random(4)); - - return "{$x}_{$yyyy}"; + throw new CouponPoolExhaustedException; } } diff --git a/database/factories/CouponFactory.php b/database/factories/CouponFactory.php index fdff13d..1fdbb6a 100644 --- a/database/factories/CouponFactory.php +++ b/database/factories/CouponFactory.php @@ -20,7 +20,7 @@ class CouponFactory extends Factory { return [ 'phone' => '6'.fake()->unique()->numerify('#######'), - 'code' => random_int(1, 9).'_'.Str::upper(Str::random(4)), + 'code' => 'TEST_'.Str::upper(Str::random(8)), ]; } } diff --git a/resources/views/verification/promotion-ended.blade.php b/resources/views/verification/promotion-ended.blade.php new file mode 100644 index 0000000..e9c20ef --- /dev/null +++ b/resources/views/verification/promotion-ended.blade.php @@ -0,0 +1,29 @@ +@extends('layouts.app') + +@section('header-back') +
+@endsection + +@section('content') +
+
+
+
+
+
+ +
+

Aksiýa tamamlandy

+

+ {{ \App\Exceptions\CouponPoolExhaustedException::MESSAGE }} +

+
+ +
+ +

+ Ähli kuponlar paýlandy. Öň agza bolan ulanyjylar öz kodlaryny «Gutlaýarys» sahypasyndan görüp bilýärler. +

+
+
+@endsection diff --git a/tests/Feature/VerificationFlowTest.php b/tests/Feature/VerificationFlowTest.php index 1ea5299..7523d8f 100644 --- a/tests/Feature/VerificationFlowTest.php +++ b/tests/Feature/VerificationFlowTest.php @@ -2,10 +2,14 @@ namespace Tests\Feature; +use App\Exceptions\CouponPoolExhaustedException; use App\Models\Coupon; use App\Models\PhoneVerification; +use App\Services\CouponCodePool; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Facades\Http; +use Mockery; +use Mockery\MockInterface; use Tests\TestCase; class VerificationFlowTest extends TestCase @@ -82,15 +86,43 @@ class VerificationFlowTest extends TestCase ->assertRedirect(route('verification.congratulations')) ->assertCookie('device_registered'); - $this->assertDatabaseHas(Coupon::class, [ - 'phone' => self::UNMASKED_PHONE, - ]); + $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() @@ -164,6 +196,13 @@ class VerificationFlowTest extends TestCase ->assertStatus(429); } + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } + private function extractOtpFromLastSms(): string { $recorded = Http::recorded()->all(); @@ -172,7 +211,7 @@ class VerificationFlowTest extends TestCase [$request] = $recorded[array_key_last($recorded)]; - preg_match('/(\d{4})/', (string) $request->data()['code'], $matches); + preg_match('/(\d{4})/', (string) $request->data()['message'], $matches); $this->assertNotEmpty($matches[1]); diff --git a/tests/Unit/CouponCodePoolTest.php b/tests/Unit/CouponCodePoolTest.php new file mode 100644 index 0000000..81182e2 --- /dev/null +++ b/tests/Unit/CouponCodePoolTest.php @@ -0,0 +1,73 @@ +codesPath = tempnam(sys_get_temp_dir(), 'codes_'); + file_put_contents($this->codesPath, "1_AAAA\n2_BBBB\n3_CCCC\n"); + } + + protected function tearDown(): void + { + if (is_file($this->codesPath)) { + unlink($this->codesPath); + } + + parent::tearDown(); + } + + public function test_all_codes_loads_from_file(): void + { + $pool = new CouponCodePool($this->codesPath); + + $this->assertSame(['1_AAAA', '2_BBBB', '3_CCCC'], $pool->allCodes()); + } + + public function test_available_codes_excludes_used_coupons(): void + { + Coupon::factory()->create(['code' => '1_AAAA', 'phone' => '61111111']); + + $pool = new CouponCodePool($this->codesPath); + + $this->assertEqualsCanonicalizing(['2_BBBB', '3_CCCC'], $pool->availableCodes()); + } + + public function test_pick_random_returns_only_unused_code(): void + { + Coupon::factory()->create(['code' => '2_BBBB', 'phone' => '61111111']); + + $pool = new CouponCodePool($this->codesPath); + + for ($i = 0; $i < 20; $i++) { + $picked = $pool->pickRandom(); + $this->assertContains($picked, ['1_AAAA', '3_CCCC']); + } + } + + public function test_has_available_is_false_when_all_codes_used(): void + { + Coupon::factory()->create(['code' => '1_AAAA', 'phone' => '61111111']); + Coupon::factory()->create(['code' => '2_BBBB', 'phone' => '62222222']); + Coupon::factory()->create(['code' => '3_CCCC', 'phone' => '63333333']); + + $pool = new CouponCodePool($this->codesPath); + + $this->assertFalse($pool->hasAvailable()); + $this->assertNull($pool->pickRandom()); + $this->assertSame([], $pool->availableCodes()); + } +} diff --git a/tests/Unit/CouponServiceTest.php b/tests/Unit/CouponServiceTest.php new file mode 100644 index 0000000..baa5ced --- /dev/null +++ b/tests/Unit/CouponServiceTest.php @@ -0,0 +1,64 @@ +mock(CouponCodePool::class, function (MockInterface $mock): void { + $mock->shouldReceive('pickRandom')->once()->andReturn('1_AAAA'); + }); + + $coupon = app(CouponService::class)->findOrCreateForPhone('61929248'); + + $this->assertSame('61929248', $coupon->phone); + $this->assertSame('1_AAAA', $coupon->code); + $this->assertDatabaseHas(Coupon::class, [ + 'phone' => '61929248', + 'code' => '1_AAAA', + ]); + } + + public function test_find_or_create_returns_existing_coupon_without_picking(): void + { + $existing = Coupon::factory()->create(['phone' => '61929248', 'code' => '2_BBBB']); + + $this->mock(CouponCodePool::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('pickRandom'); + }); + + $coupon = app(CouponService::class)->findOrCreateForPhone('61929248'); + + $this->assertTrue($existing->is($coupon)); + } + + public function test_find_or_create_throws_when_pool_exhausted(): void + { + $this->mock(CouponCodePool::class, function (MockInterface $mock): void { + $mock->shouldReceive('pickRandom')->andReturn(null); + }); + + $this->expectException(CouponPoolExhaustedException::class); + + app(CouponService::class)->findOrCreateForPhone('61929248'); + } + + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } +}