Merge pull request 'test(unit): 補齊三個純邏輯 Unit test——狀態機、Enum 完整性、Email 正規化' (#37) from feat/unit-test-planning into master
Run Tests / test (push) Successful in 1m53s
Deploy Production / deploy (push) Successful in 11s

Reviewed-on: #37
This commit was merged in pull request #37.
This commit is contained in:
2026-06-16 09:35:25 +00:00
3 changed files with 279 additions and 0 deletions
+67
View File
@@ -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 狀態,避免未知狀態繞過狀態機'
);
}
}
+155
View File
@@ -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],
];
}
}
+57
View File
@@ -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"));
}
}