feat:實作通知系統 — 站內通知、Email 通知、Polling 機制

後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:26:14 +08:00
parent 4baa4cb52b
commit 03f8caf3e9
46 changed files with 2709 additions and 21 deletions
+556
View File
@@ -0,0 +1,556 @@
<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReviewTest extends TestCase
{
use RefreshDatabase;
// ── 測試資料建立輔助 ─────────────────────────────────────
private function createMember(array $attrs = []): User
{
return User::factory()->create(array_merge(['role' => 'member'], $attrs));
}
private function createAdmin(): User
{
return User::factory()->create(['role' => 'admin']);
}
private function createOffer(): DivingOffer
{
$provider = User::factory()->create(['role' => 'provider']);
return DivingOffer::create([
'provider_id' => $provider->id,
'title' => '測試潛水課程',
'location' => '台北',
'spot' => '龍洞',
'rating' => 0,
'reviews' => 0,
'price' => 3000,
'badges' => [],
'description' => '測試用課程',
'tag' => 'beginner',
'region' => 'north',
]);
}
private function createCompletedBooking(User $member, DivingOffer $offer): Booking
{
$schedule = CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $offer->provider_id,
'scheduled_date' => now()->subDays(7)->toDateString(),
'start_time' => '09:00:00',
'max_participants' => 10,
'current_participants' => 1,
'status' => 'open',
]);
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => $offer->price,
'status' => BookingStatus::Completed->value,
]);
}
private function createReview(User $member, DivingOffer $offer, array $attrs = []): Review
{
return Review::create(array_merge([
'diving_offer_id' => $offer->id,
'member_id' => $member->id,
'rating' => 5,
'comment' => '很棒的課程!',
], $attrs));
}
// ── 公開列表 ─────────────────────────────────────────────
public function test_public_list_returns_empty_summary_when_no_reviews(): void
{
$offer = $this->createOffer();
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$response->assertOk()
->assertJson([
'status' => true,
'data' => [
'summary' => [
'average' => 0,
'total' => 0,
],
'reviews' => [],
],
]);
}
public function test_public_list_returns_distribution_with_all_keys(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer, ['rating' => 5]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$dist = $response->json('data.summary.distribution');
$this->assertArrayHasKey('1', $dist);
$this->assertArrayHasKey('2', $dist);
$this->assertArrayHasKey('3', $dist);
$this->assertArrayHasKey('4', $dist);
$this->assertArrayHasKey('5', $dist);
$this->assertEquals(1, $dist['5']);
$this->assertEquals(0, $dist['1']);
}
public function test_public_list_anonymous_user_has_no_is_mine_field(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews");
$review = $response->json('data.reviews.0');
$this->assertArrayNotHasKey('is_mine', $review);
$this->assertFalse($review['has_voted']);
$this->assertEquals('匿名潛水者', $review['reviewer_name']);
}
public function test_public_list_authenticated_user_has_is_mine_field(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$response = $this->actingAs($member)->getJson("/api/diving-offers/{$offer->id}/reviews");
$review = $response->json('data.reviews.0');
$this->assertArrayHasKey('is_mine', $review);
$this->assertTrue($review['is_mine']);
}
public function test_public_list_has_voted_is_true_when_voted(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]);
$response = $this->actingAs($voter)->getJson("/api/diving-offers/{$offer->id}/reviews");
$this->assertTrue($response->json('data.reviews.0.has_voted'));
}
public function test_public_list_sort_by_helpful(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member, $offer, ['rating' => 3, 'helpful_count' => 10]);
// Need a second review but UNIQUE constraint prevents same member/offer
// So create another member
$member2 = $this->createMember();
$r2 = $this->createReview($member2, $offer, ['rating' => 5, 'helpful_count' => 1]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=helpful");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r1->id, $ids[0]);
}
public function test_public_list_sort_by_rating(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer, ['rating' => 2]);
$r2 = $this->createReview($member2, $offer, ['rating' => 5]);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=rating");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r2->id, $ids[0]);
}
public function test_public_list_sort_by_newest(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer);
$r2 = $this->createReview($member2, $offer);
$response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=newest");
$ids = $response->json('data.reviews.*.id');
$this->assertEquals($r2->id, $ids[0]);
}
// ── 新增評價 ──────────────────────────────────────────────
public function test_guest_cannot_create_review(): void
{
$offer = $this->createOffer();
$this->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '很棒',
])->assertUnauthorized();
}
public function test_member_cannot_review_without_completed_booking(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '很棒',
])->assertStatus(403)
->assertJson(['message' => '須完成此課程後才能評價']);
}
public function test_member_can_create_review_with_completed_booking(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$response = $this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 5,
'comment' => '課程很棒!',
]);
$response->assertStatus(201)
->assertJson(['status' => true]);
$this->assertDatabaseHas('reviews', [
'diving_offer_id' => $offer->id,
'member_id' => $member->id,
'rating' => 5,
]);
}
public function test_create_review_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 4,
'comment' => '不錯',
]);
$offer->refresh();
$this->assertEquals(4.0, $offer->rating);
$this->assertEquals(1, $offer->reviews);
}
public function test_member_cannot_review_same_offer_twice(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->createReview($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 3,
'comment' => '重複',
])->assertStatus(422)
->assertJson(['message' => '已評價,如需修改請使用編輯功能']);
}
public function test_create_review_validates_rating_range(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createCompletedBooking($member, $offer);
$this->actingAs($member)->postJson('/api/member/reviews', [
'diving_offer_id' => $offer->id,
'rating' => 6,
'comment' => '超出範圍',
])->assertStatus(422);
}
// ── 修改評價 ──────────────────────────────────────────────
public function test_member_can_update_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 3,
'comment' => '修改後的評論',
])->assertOk()->assertJson(['status' => true]);
$this->assertEquals(3, $review->fresh()->rating);
}
public function test_member_cannot_update_others_review(): void
{
$owner = $this->createMember();
$other = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($other)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 1,
])->assertStatus(403)
->assertJson(['message' => '無權修改此評價']);
}
public function test_update_creates_review_edit_and_sets_is_edited(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5, 'comment' => '原始評論']);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [
'rating' => 4,
'comment' => '修改後',
]);
$this->assertDatabaseHas('review_edits', [
'review_id' => $review->id,
'old_rating' => 5,
]);
$this->assertTrue($review->fresh()->is_edited);
}
public function test_second_update_overwrites_review_edit(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 4]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 3]);
$this->assertDatabaseCount('review_edits', 1);
$this->assertEquals(3, $review->fresh()->rating);
}
public function test_update_review_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
// Manually set offer rating to 5 first
$offer->update(['rating' => 5, 'reviews' => 1]);
$this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 2]);
$offer->refresh();
$this->assertEquals(2.0, $offer->rating);
}
// ── 刪除評價 ──────────────────────────────────────────────
public function test_member_can_delete_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}")
->assertOk()->assertJson(['status' => true]);
$this->assertDatabaseMissing('reviews', ['id' => $review->id]);
}
public function test_member_cannot_delete_others_review(): void
{
$owner = $this->createMember();
$other = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($other)->deleteJson("/api/member/reviews/{$review->id}")
->assertStatus(403)
->assertJson(['message' => '無權刪除此評價']);
}
public function test_delete_review_recalculates_offer_rating(): void
{
$member1 = $this->createMember();
$member2 = $this->createMember();
$offer = $this->createOffer();
$r1 = $this->createReview($member1, $offer, ['rating' => 4]);
$r2 = $this->createReview($member2, $offer, ['rating' => 2]);
$offer->update(['rating' => 3.0, 'reviews' => 2]);
$this->actingAs($member2)->deleteJson("/api/member/reviews/{$r2->id}");
$offer->refresh();
$this->assertEquals(4.0, $offer->rating);
$this->assertEquals(1, $offer->reviews);
}
public function test_delete_review_resets_offer_rating_to_zero_when_no_reviews(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$offer->update(['rating' => 5.0, 'reviews' => 1]);
$this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}");
$offer->refresh();
$this->assertEquals(0, $offer->rating);
$this->assertEquals(0, $offer->reviews);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function test_guest_cannot_vote(): void
{
$owner = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->postJson("/api/reviews/{$review->id}/helpful")
->assertUnauthorized();
}
public function test_member_cannot_vote_own_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$this->actingAs($member)->postJson("/api/reviews/{$review->id}/helpful")
->assertStatus(422)
->assertJson(['message' => '不可對自己的評價投票']);
}
public function test_member_can_vote_helpful(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response->assertOk()
->assertJson(['data' => ['helpful_count' => 1, 'has_voted' => true]]);
$this->assertDatabaseHas('review_votes', [
'review_id' => $review->id,
'member_id' => $voter->id,
]);
}
public function test_second_vote_toggles_off(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer);
$this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$response->assertOk()
->assertJson(['data' => ['helpful_count' => 0, 'has_voted' => false]]);
$this->assertDatabaseMissing('review_votes', [
'review_id' => $review->id,
'member_id' => $voter->id,
]);
}
public function test_helpful_count_does_not_go_below_zero(): void
{
$owner = $this->createMember();
$voter = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($owner, $offer, ['helpful_count' => 0]);
// Force a vote record without incrementing (edge case simulation)
ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]);
$response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful");
$this->assertGreaterThanOrEqual(0, $response->json('data.helpful_count'));
}
// ── Admin 評價管理 ────────────────────────────────────────
public function test_admin_can_list_all_reviews(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$this->createReview($member, $offer);
$admin = $this->createAdmin();
$response = $this->actingAs($admin)->getJson('/api/admin/reviews');
$response->assertOk()->assertJson(['status' => true]);
$this->assertCount(1, $response->json('data'));
$item = $response->json('data.0');
$this->assertArrayHasKey('offer_title', $item);
$this->assertArrayHasKey('member_email', $item);
$this->assertArrayHasKey('rating', $item);
$this->assertArrayHasKey('comment', $item);
}
public function test_admin_can_delete_review(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer);
$admin = $this->createAdmin();
$this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}")
->assertOk()->assertJson(['status' => true]);
$this->assertDatabaseMissing('reviews', ['id' => $review->id]);
}
public function test_admin_delete_recalculates_offer_rating(): void
{
$member = $this->createMember();
$offer = $this->createOffer();
$review = $this->createReview($member, $offer, ['rating' => 5]);
$offer->update(['rating' => 5.0, 'reviews' => 1]);
$admin = $this->createAdmin();
$this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}");
$offer->refresh();
$this->assertEquals(0, $offer->rating);
$this->assertEquals(0, $offer->reviews);
}
public function test_non_admin_cannot_access_admin_reviews(): void
{
$member = $this->createMember();
$this->actingAs($member)->getJson('/api/admin/reviews')
->assertForbidden();
}
}