From 35a9f83989c6fc413f9259eba691c2c35011342e Mon Sep 17 00:00:00 2001 From: gbanyan Date: Fri, 13 Feb 2026 12:10:39 +0800 Subject: [PATCH] feat(01-02): add withCount for notes and comprehensive tests - AdminMemberController index() now includes withCount('notes') - Created MemberNoteTest with 7 feature tests - Tests cover: creation, retrieval, validation, audit logging, authorization, N+1 prevention, ordering - All new tests passing (7/7) - No regressions in existing test suite --- .../Controllers/AdminMemberController.php | 2 +- tests/Feature/Admin/MemberNoteTest.php | 205 ++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Admin/MemberNoteTest.php diff --git a/app/Http/Controllers/AdminMemberController.php b/app/Http/Controllers/AdminMemberController.php index a4ef94e..730bd3c 100644 --- a/app/Http/Controllers/AdminMemberController.php +++ b/app/Http/Controllers/AdminMemberController.php @@ -15,7 +15,7 @@ class AdminMemberController extends Controller { public function index(Request $request) { - $query = Member::query()->with('user'); + $query = Member::query()->with('user')->withCount('notes'); // Text search (name, email, phone, Line ID, national ID) if ($search = $request->string('search')->toString()) { diff --git a/tests/Feature/Admin/MemberNoteTest.php b/tests/Feature/Admin/MemberNoteTest.php new file mode 100644 index 0000000..459347c --- /dev/null +++ b/tests/Feature/Admin/MemberNoteTest.php @@ -0,0 +1,205 @@ +artisan('db:seed', ['--class' => 'RoleSeeder']); + } + + protected function createAdminUser(): User + { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + return $admin; + } + + public function test_admin_can_create_note_for_member(): void + { + $admin = $this->createAdminUser(); + $member = Member::factory()->create(); + + $response = $this->actingAs($admin)->postJson( + route('admin.members.notes.store', $member), + ['content' => 'Test note content'] + ); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'note' => ['id', 'content', 'created_at', 'author'], + 'message', + ]); + $response->assertJson([ + 'note' => [ + 'content' => 'Test note content', + 'author' => [ + 'id' => $admin->id, + 'name' => $admin->name, + ], + ], + 'message' => '備忘錄已新增', + ]); + + $this->assertDatabaseHas('notes', [ + 'notable_type' => 'member', + 'notable_id' => $member->id, + 'content' => 'Test note content', + 'author_user_id' => $admin->id, + ]); + } + + public function test_admin_can_retrieve_notes_for_member(): void + { + $admin = $this->createAdminUser(); + $member = Member::factory()->create(); + + // Create 3 notes + Note::factory()->forMember($member)->byAuthor($admin)->create(['content' => 'Note 1']); + Note::factory()->forMember($member)->byAuthor($admin)->create(['content' => 'Note 2']); + Note::factory()->forMember($member)->byAuthor($admin)->create(['content' => 'Note 3']); + + $response = $this->actingAs($admin)->getJson( + route('admin.members.notes.index', $member) + ); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'notes' => [ + '*' => ['id', 'content', 'created_at', 'updated_at', 'author'], + ], + ]); + + $notes = $response->json('notes'); + $this->assertCount(3, $notes); + + // Verify each note has required fields + foreach ($notes as $note) { + $this->assertArrayHasKey('content', $note); + $this->assertArrayHasKey('created_at', $note); + $this->assertArrayHasKey('author', $note); + $this->assertArrayHasKey('name', $note['author']); + } + } + + public function test_note_creation_requires_content(): void + { + $admin = $this->createAdminUser(); + $member = Member::factory()->create(); + + $response = $this->actingAs($admin)->postJson( + route('admin.members.notes.store', $member), + ['content' => ''] + ); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('content'); + } + + public function test_note_creation_logs_audit_trail(): void + { + $admin = $this->createAdminUser(); + $member = Member::factory()->create(); + + $this->actingAs($admin)->postJson( + route('admin.members.notes.store', $member), + ['content' => 'Audit test note'] + ); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'note.created', + ]); + + // Verify the audit log has the correct metadata + $auditLog = \App\Models\AuditLog::where('action', 'note.created')->first(); + $this->assertNotNull($auditLog); + $this->assertEquals($admin->id, $auditLog->user_id); + $this->assertArrayHasKey('member_id', $auditLog->metadata); + $this->assertEquals($member->id, $auditLog->metadata['member_id']); + $this->assertEquals($member->full_name, $auditLog->metadata['member_name']); + } + + public function test_non_admin_cannot_create_note(): void + { + $user = User::factory()->create(); + // User has no admin role and no permissions + $member = Member::factory()->create(); + + $response = $this->actingAs($user)->postJson( + route('admin.members.notes.store', $member), + ['content' => 'Should fail'] + ); + + $response->assertStatus(403); + } + + public function test_member_list_includes_notes_count(): void + { + $admin = $this->createAdminUser(); + $member1 = Member::factory()->create(); + $member2 = Member::factory()->create(); + + // Create 3 notes for member1, 0 notes for member2 + Note::factory()->forMember($member1)->byAuthor($admin)->count(3)->create(); + + $response = $this->actingAs($admin)->get(route('admin.members.index')); + + $response->assertStatus(200); + $response->assertViewHas('members'); + + $members = $response->viewData('members'); + + // Find the members in the paginated result + $foundMember1 = $members->firstWhere('id', $member1->id); + $foundMember2 = $members->firstWhere('id', $member2->id); + + $this->assertNotNull($foundMember1); + $this->assertNotNull($foundMember2); + $this->assertEquals(3, $foundMember1->notes_count); + $this->assertEquals(0, $foundMember2->notes_count); + } + + public function test_notes_returned_newest_first(): void + { + $admin = $this->createAdminUser(); + $member = Member::factory()->create(); + + // Create notes with different timestamps + $oldestNote = Note::factory()->forMember($member)->byAuthor($admin)->create([ + 'content' => 'Oldest note', + 'created_at' => now()->subDays(3), + ]); + + $middleNote = Note::factory()->forMember($member)->byAuthor($admin)->create([ + 'content' => 'Middle note', + 'created_at' => now()->subDays(1), + ]); + + $newestNote = Note::factory()->forMember($member)->byAuthor($admin)->create([ + 'content' => 'Newest note', + 'created_at' => now(), + ]); + + $response = $this->actingAs($admin)->getJson( + route('admin.members.notes.index', $member) + ); + + $response->assertStatus(200); + $notes = $response->json('notes'); + + // Verify ordering (newest first) + $this->assertEquals('Newest note', $notes[0]['content']); + $this->assertEquals('Middle note', $notes[1]['content']); + $this->assertEquals('Oldest note', $notes[2]['content']); + } +}