codes.txt

This commit is contained in:
Mekan1206
2026-06-03 21:24:00 +05:00
parent c74330cfaf
commit fa3202288f
9 changed files with 341 additions and 17 deletions

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class CouponPoolExhaustedException extends RuntimeException
{
public const MESSAGE = 'Aksiýa tamamlandy. Gyzyklanmagyňyz üçin sag boluň.';
public function __construct()
{
parent::__construct(self::MESSAGE);
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Http\Controllers;
use App\Enums\OtpVerificationResult;
use App\Exceptions\CouponPoolExhaustedException;
use App\Http\Requests\SendOtpRequest;
use App\Http\Requests\VerifyOtpRequest;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use App\Services\CouponService;
use App\Services\OtpService;
use Illuminate\Contracts\View\View;
@@ -20,6 +22,7 @@ class VerificationController extends Controller
public function __construct(
private OtpService $otpService,
private CouponService $couponService,
private CouponCodePool $couponCodePool,
) {}
public function index(): View|RedirectResponse
@@ -28,6 +31,10 @@ class VerificationController extends Controller
return $redirect;
}
if (! $this->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.']);
}
try {
$coupon = $this->couponService->findOrCreateForPhone($phone);
} catch (CouponPoolExhaustedException) {
return back()->withErrors(['otp' => CouponPoolExhaustedException::MESSAGE]);
}
session()->put('coupon_code', $coupon->code);

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\Coupon;
class CouponCodePool
{
/** @var list<string>|null */
private ?array $allCodesCache = null;
public function __construct(
private ?string $codesPath = null,
) {}
/**
* @return list<string>
*/
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<string>
*/
public function usedCodes(): array
{
return Coupon::query()->pluck('code')->all();
}
/**
* @return list<string>
*/
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)];
}
}

View File

@@ -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;
}
}

View File

@@ -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)),
];
}
}

View File

@@ -0,0 +1,29 @@
@extends('layouts.app')
@section('header-back')
<div class="w-10"></div>
@endsection
@section('content')
<main class="flex-grow pt-2xl px-container-padding pb-xl flex flex-col mt-16 max-w-md mx-auto w-full">
<div class="flex gap-xs mb-2xl justify-center">
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
</div>
<section class="space-y-base mb-2xl text-center">
<h2 class="font-headline-xl text-headline-xl text-on-background">Aksiýa tamamlandy</h2>
<p class="font-body-md text-body-md text-on-surface-variant">
{{ \App\Exceptions\CouponPoolExhaustedException::MESSAGE }}
</p>
</section>
<div class="flex items-start gap-sm bg-secondary-container/30 p-md rounded border border-secondary-container">
<x-app-icon name="info" class="w-6 h-6 text-on-secondary-container mt-0.5 shrink-0" />
<p class="font-body-sm text-body-sm text-on-secondary-container text-left">
Ähli kuponlar paýlandy. Öň agza bolan ulanyjylar öz kodlaryny «Gutlaýarys» sahypasyndan görüp bilýärler.
</p>
</div>
</main>
@endsection

View File

@@ -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]);

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Unit;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Tests\TestCase;
class CouponCodePoolTest extends TestCase
{
use LazilyRefreshDatabase;
private string $codesPath;
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Exceptions\CouponPoolExhaustedException;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use App\Services\CouponService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class CouponServiceTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_find_or_create_assigns_code_from_pool(): void
{
$this->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();
}
}