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
This commit is contained in:
2026-02-13 12:10:39 +08:00
parent e8bef5bc06
commit 35a9f83989
2 changed files with 206 additions and 1 deletions

View File

@@ -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()) {

View File

@@ -0,0 +1,205 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Member;
use App\Models\Note;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MemberNoteTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}