test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化
Run Tests / test (pull_request) Failing after 1m8s
Run Tests / test (pull_request) Failing after 1m8s
BookingTransitionTest:驗證 canTransitionTo() 所有合法/非法轉換與終態不可逃脫(18 案例) BookingStatusTest:驗證 7 個狀態完整性、from/tryFrom、VALID_TRANSITIONS 覆蓋一致性(10 案例) NormalizesEmailTest:驗證大小寫與空白正規化邏輯(5 案例) 全部使用 PHPUnit 原生 TestCase,不需要資料庫連線,執行 1.82s。 全套件 187 passed / 464 assertions,零 regression。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Enums;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* BookingStatus enum 完整性測試。
|
||||
* 確保 7 個狀態值與 booking-lifecycle 規格一致,
|
||||
* 防止新增/刪除狀態時忘記同步 VALID_TRANSITIONS 常數。
|
||||
*/
|
||||
class BookingStatusTest extends TestCase
|
||||
{
|
||||
public function test_all_seven_cases_exist(): void
|
||||
{
|
||||
$values = array_map(fn ($case) => $case->value, BookingStatus::cases());
|
||||
|
||||
$this->assertEqualsCanonicalizing([
|
||||
'pending',
|
||||
'confirmed',
|
||||
'completed',
|
||||
'rejected',
|
||||
'expired',
|
||||
'member_cancelled',
|
||||
'provider_cancelled',
|
||||
], $values);
|
||||
}
|
||||
|
||||
/** @dataProvider validStringProvider */
|
||||
public function test_from_returns_correct_case(string $value, BookingStatus $expected): void
|
||||
{
|
||||
$this->assertSame($expected, BookingStatus::from($value));
|
||||
}
|
||||
|
||||
public static function validStringProvider(): array
|
||||
{
|
||||
return [
|
||||
['pending', BookingStatus::Pending],
|
||||
['confirmed', BookingStatus::Confirmed],
|
||||
['completed', BookingStatus::Completed],
|
||||
['rejected', BookingStatus::Rejected],
|
||||
['expired', BookingStatus::Expired],
|
||||
['member_cancelled', BookingStatus::MemberCancelled],
|
||||
['provider_cancelled', BookingStatus::ProviderCancelled],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_try_from_returns_null_for_invalid_value(): void
|
||||
{
|
||||
$this->assertNull(BookingStatus::tryFrom('invalid_status'));
|
||||
$this->assertNull(BookingStatus::tryFrom(''));
|
||||
$this->assertNull(BookingStatus::tryFrom('PENDING'));
|
||||
}
|
||||
|
||||
public function test_valid_transitions_covers_all_cases(): void
|
||||
{
|
||||
$allCases = array_map(fn ($case) => $case->value, BookingStatus::cases());
|
||||
$transitionKeys = array_keys(\App\Models\Booking::VALID_TRANSITIONS);
|
||||
|
||||
$this->assertEqualsCanonicalizing(
|
||||
$allCases,
|
||||
$transitionKeys,
|
||||
'VALID_TRANSITIONS 必須包含所有 BookingStatus 狀態,避免未知狀態繞過狀態機'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Booking::canTransitionTo() 是純 PHP 邏輯(只讀 VALID_TRANSITIONS 常數),
|
||||
* 不需要資料庫連線,用 PHPUnit 原生 TestCase 而非 Laravel TestCase。
|
||||
*
|
||||
* 規格依據:booking-lifecycle spec VALID_TRANSITIONS 表。
|
||||
* 終態(completed/rejected/expired/member_cancelled/provider_cancelled)不可再轉換,
|
||||
* 破壞此不變式會導致名額帳務錯誤與評價資格污染。
|
||||
*/
|
||||
class BookingTransitionTest extends TestCase
|
||||
{
|
||||
private function bookingWithStatus(BookingStatus $status): Booking
|
||||
{
|
||||
$booking = new Booking();
|
||||
$booking->status = $status;
|
||||
return $booking;
|
||||
}
|
||||
|
||||
// ── pending 合法轉換 ─────────────────────────────────────
|
||||
|
||||
public function test_pending_can_transition_to_confirmed(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::Confirmed)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_pending_can_transition_to_rejected(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::Rejected)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_pending_can_transition_to_expired(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_pending_can_transition_to_member_cancelled(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::MemberCancelled)
|
||||
);
|
||||
}
|
||||
|
||||
// ── pending 非法轉換 ─────────────────────────────────────
|
||||
|
||||
public function test_pending_cannot_transition_to_completed(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::Completed)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_pending_cannot_transition_to_provider_cancelled(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->bookingWithStatus(BookingStatus::Pending)
|
||||
->canTransitionTo(BookingStatus::ProviderCancelled)
|
||||
);
|
||||
}
|
||||
|
||||
// ── confirmed 合法轉換 ───────────────────────────────────
|
||||
|
||||
public function test_confirmed_can_transition_to_completed(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::Completed)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_confirmed_can_transition_to_member_cancelled(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::MemberCancelled)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_confirmed_can_transition_to_provider_cancelled(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::ProviderCancelled)
|
||||
);
|
||||
}
|
||||
|
||||
// ── confirmed 非法轉換 ───────────────────────────────────
|
||||
|
||||
public function test_confirmed_cannot_transition_to_pending(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::Pending)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_confirmed_cannot_transition_to_rejected(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::Rejected)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_confirmed_cannot_transition_to_expired(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
$this->bookingWithStatus(BookingStatus::Confirmed)
|
||||
->canTransitionTo(BookingStatus::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 終態:不可再轉換 ─────────────────────────────────────
|
||||
|
||||
/** @dataProvider terminalStatuses */
|
||||
public function test_terminal_status_cannot_transition_to_any_state(BookingStatus $terminal): void
|
||||
{
|
||||
$booking = $this->bookingWithStatus($terminal);
|
||||
|
||||
foreach (BookingStatus::cases() as $target) {
|
||||
$this->assertFalse(
|
||||
$booking->canTransitionTo($target),
|
||||
"終態 {$terminal->value} 不應能轉換至 {$target->value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function terminalStatuses(): array
|
||||
{
|
||||
return [
|
||||
'completed' => [BookingStatus::Completed],
|
||||
'rejected' => [BookingStatus::Rejected],
|
||||
'expired' => [BookingStatus::Expired],
|
||||
'member_cancelled' => [BookingStatus::MemberCancelled],
|
||||
'provider_cancelled' => [BookingStatus::ProviderCancelled],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Traits;
|
||||
|
||||
use App\Traits\NormalizesEmail;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* NormalizesEmail::normalizeEmail() 是純字串邏輯。
|
||||
* 帳號鎖定計數器(AuthLockoutTest)依賴此邏輯確保大小寫與空白
|
||||
* 不會被視為不同帳號而繞過鎖定——這裡驗證邏輯本身,
|
||||
* 而非驗證它被正確呼叫(那是 AuthLockoutTest 的職責)。
|
||||
*/
|
||||
class NormalizesEmailTest extends TestCase
|
||||
{
|
||||
private object $subject;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// normalizeEmail 是 private,透過匿名類別暴露供測試
|
||||
$this->subject = new class {
|
||||
use NormalizesEmail;
|
||||
|
||||
public function normalize(string $email): string
|
||||
{
|
||||
return $this->normalizeEmail($email);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function test_uppercase_is_lowercased(): void
|
||||
{
|
||||
$this->assertSame('user@example.com', $this->subject->normalize('USER@EXAMPLE.COM'));
|
||||
}
|
||||
|
||||
public function test_leading_and_trailing_whitespace_is_trimmed(): void
|
||||
{
|
||||
$this->assertSame('user@example.com', $this->subject->normalize(' user@example.com '));
|
||||
}
|
||||
|
||||
public function test_mixed_case_and_whitespace_are_both_normalized(): void
|
||||
{
|
||||
$this->assertSame('user@example.com', $this->subject->normalize(' User@Example.COM '));
|
||||
}
|
||||
|
||||
public function test_already_normalized_email_is_unchanged(): void
|
||||
{
|
||||
$this->assertSame('user@example.com', $this->subject->normalize('user@example.com'));
|
||||
}
|
||||
|
||||
public function test_tab_whitespace_is_trimmed(): void
|
||||
{
|
||||
$this->assertSame('user@example.com', $this->subject->normalize("\tuser@example.com\t"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user