codes.txt
This commit is contained in:
15
app/Exceptions/CouponPoolExhaustedException.php
Normal file
15
app/Exceptions/CouponPoolExhaustedException.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\OtpVerificationResult;
|
use App\Enums\OtpVerificationResult;
|
||||||
|
use App\Exceptions\CouponPoolExhaustedException;
|
||||||
use App\Http\Requests\SendOtpRequest;
|
use App\Http\Requests\SendOtpRequest;
|
||||||
use App\Http\Requests\VerifyOtpRequest;
|
use App\Http\Requests\VerifyOtpRequest;
|
||||||
use App\Models\Coupon;
|
use App\Models\Coupon;
|
||||||
|
use App\Services\CouponCodePool;
|
||||||
use App\Services\CouponService;
|
use App\Services\CouponService;
|
||||||
use App\Services\OtpService;
|
use App\Services\OtpService;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@@ -20,6 +22,7 @@ class VerificationController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private OtpService $otpService,
|
private OtpService $otpService,
|
||||||
private CouponService $couponService,
|
private CouponService $couponService,
|
||||||
|
private CouponCodePool $couponCodePool,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(): View|RedirectResponse
|
public function index(): View|RedirectResponse
|
||||||
@@ -28,6 +31,10 @@ class VerificationController extends Controller
|
|||||||
return $redirect;
|
return $redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->couponCodePool->hasAvailable()) {
|
||||||
|
return view('verification.promotion-ended');
|
||||||
|
}
|
||||||
|
|
||||||
return view('verification.index');
|
return view('verification.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +51,10 @@ class VerificationController extends Controller
|
|||||||
return back()->withErrors(['phone' => 'Bu telefon belgisi eýýäm ulanyldy.'])->withInput();
|
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)) {
|
if (! $this->otpService->issue($phone)) {
|
||||||
return back()->withErrors(['phone' => 'SMS iberilmedi. Soňrak synanyşyň.'])->withInput();
|
return back()->withErrors(['phone' => 'SMS iberilmedi. Soňrak synanyşyň.'])->withInput();
|
||||||
}
|
}
|
||||||
@@ -63,6 +74,12 @@ class VerificationController extends Controller
|
|||||||
return redirect()->route('verification.index');
|
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', [
|
return view('verification.verify', [
|
||||||
'phone' => session()->get('verify_phone'),
|
'phone' => session()->get('verify_phone'),
|
||||||
]);
|
]);
|
||||||
@@ -84,6 +101,10 @@ class VerificationController extends Controller
|
|||||||
return redirect()->route('verification.congratulations');
|
return redirect()->route('verification.congratulations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->couponCodePool->hasAvailable()) {
|
||||||
|
return redirect()->route('verification.index');
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->otpService->issue($phone)) {
|
if (! $this->otpService->issue($phone)) {
|
||||||
return back()->withErrors(['otp' => 'SMS iberilmedi. Soňrak synanyşyň.']);
|
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.']);
|
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);
|
session()->put('coupon_code', $coupon->code);
|
||||||
|
|
||||||
|
|||||||
77
app/Services/CouponCodePool.php
Normal file
77
app/Services/CouponCodePool.php
Normal 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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\CouponPoolExhaustedException;
|
||||||
use App\Models\Coupon;
|
use App\Models\Coupon;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class CouponService
|
class CouponService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CouponCodePool $couponCodePool,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function findOrCreateForPhone(string $phone): Coupon
|
public function findOrCreateForPhone(string $phone): Coupon
|
||||||
{
|
{
|
||||||
$existing = Coupon::query()->where('phone', $phone)->first();
|
$existing = Coupon::query()->where('phone', $phone)->first();
|
||||||
@@ -22,24 +26,22 @@ class CouponService
|
|||||||
private function createWithUniqueCode(string $phone): Coupon
|
private function createWithUniqueCode(string $phone): Coupon
|
||||||
{
|
{
|
||||||
for ($attempt = 0; $attempt < 10; $attempt++) {
|
for ($attempt = 0; $attempt < 10; $attempt++) {
|
||||||
|
$code = $this->couponCodePool->pickRandom();
|
||||||
|
|
||||||
|
if ($code === null) {
|
||||||
|
throw new CouponPoolExhaustedException;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return Coupon::query()->create([
|
return Coupon::query()->create([
|
||||||
'phone' => $phone,
|
'phone' => $phone,
|
||||||
'code' => $this->generateCouponCode(),
|
'code' => $code,
|
||||||
]);
|
]);
|
||||||
} catch (UniqueConstraintViolationException) {
|
} catch (UniqueConstraintViolationException) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new \RuntimeException('Unable to generate a unique coupon code.');
|
throw new CouponPoolExhaustedException;
|
||||||
}
|
|
||||||
|
|
||||||
private function generateCouponCode(): string
|
|
||||||
{
|
|
||||||
$x = random_int(1, 9);
|
|
||||||
$yyyy = Str::upper(Str::random(4));
|
|
||||||
|
|
||||||
return "{$x}_{$yyyy}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class CouponFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'phone' => '6'.fake()->unique()->numerify('#######'),
|
'phone' => '6'.fake()->unique()->numerify('#######'),
|
||||||
'code' => random_int(1, 9).'_'.Str::upper(Str::random(4)),
|
'code' => 'TEST_'.Str::upper(Str::random(8)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
resources/views/verification/promotion-ended.blade.php
Normal file
29
resources/views/verification/promotion-ended.blade.php
Normal 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
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Exceptions\CouponPoolExhaustedException;
|
||||||
use App\Models\Coupon;
|
use App\Models\Coupon;
|
||||||
use App\Models\PhoneVerification;
|
use App\Models\PhoneVerification;
|
||||||
|
use App\Services\CouponCodePool;
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Mockery;
|
||||||
|
use Mockery\MockInterface;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class VerificationFlowTest extends TestCase
|
class VerificationFlowTest extends TestCase
|
||||||
@@ -82,15 +86,43 @@ class VerificationFlowTest extends TestCase
|
|||||||
->assertRedirect(route('verification.congratulations'))
|
->assertRedirect(route('verification.congratulations'))
|
||||||
->assertCookie('device_registered');
|
->assertCookie('device_registered');
|
||||||
|
|
||||||
$this->assertDatabaseHas(Coupon::class, [
|
$coupon = Coupon::query()->where('phone', self::UNMASKED_PHONE)->first();
|
||||||
'phone' => self::UNMASKED_PHONE,
|
|
||||||
]);
|
$this->assertNotNull($coupon);
|
||||||
|
$this->assertContains(
|
||||||
|
$coupon->code,
|
||||||
|
(new CouponCodePool)->allCodes(),
|
||||||
|
);
|
||||||
|
|
||||||
$this->get(route('verification.congratulations'))
|
$this->get(route('verification.congratulations'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertViewHas('code');
|
->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
|
public function test_verify_otp_rejects_wrong_code(): void
|
||||||
{
|
{
|
||||||
PhoneVerification::factory()
|
PhoneVerification::factory()
|
||||||
@@ -164,6 +196,13 @@ class VerificationFlowTest extends TestCase
|
|||||||
->assertStatus(429);
|
->assertStatus(429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
private function extractOtpFromLastSms(): string
|
private function extractOtpFromLastSms(): string
|
||||||
{
|
{
|
||||||
$recorded = Http::recorded()->all();
|
$recorded = Http::recorded()->all();
|
||||||
@@ -172,7 +211,7 @@ class VerificationFlowTest extends TestCase
|
|||||||
|
|
||||||
[$request] = $recorded[array_key_last($recorded)];
|
[$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]);
|
$this->assertNotEmpty($matches[1]);
|
||||||
|
|
||||||
|
|||||||
73
tests/Unit/CouponCodePoolTest.php
Normal file
73
tests/Unit/CouponCodePoolTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tests/Unit/CouponServiceTest.php
Normal file
64
tests/Unit/CouponServiceTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user