user = $this->createUserWithPermissions(); $this->token = $this->user->createToken('test-token')->plainTextToken; } /** * 프로필 권한이 부여된 사용자 생성 헬퍼 */ private function createUserWithPermissions(array $permissions = [ 'core.profile.read', 'core.profile.update', ]): User { $user = User::factory()->create([ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'password' => bcrypt('password123'), ]); $permissionIds = []; foreach ($permissions as $permIdentifier) { $permission = Permission::firstOrCreate( ['identifier' => $permIdentifier], [ 'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]), 'description' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]), 'extension_type' => 'core', 'extension_identifier' => 'core', 'type' => 'user', ] ); $permissionIds[] = $permission->id; } $testRole = Role::create([ 'identifier' => 'user_test_'.uniqid(), 'name' => json_encode(['ko' => '테스트 사용자', 'en' => 'Test User']), 'is_active' => true, ]); $testRole->permissions()->sync($permissionIds); $user->roles()->attach($testRole->id, ['assigned_at' => now()]); return $user->fresh(); } /** * 인증된 요청 헬퍼 메서드 */ private function authRequest(): static { return $this->withHeaders([ 'Authorization' => 'Bearer '.$this->token, 'Accept' => 'application/json', ]); } /** * JSON 요청 헬퍼 메서드 */ private function jsonRequest(): static { return $this->withHeaders([ 'Accept' => 'application/json', ]); } // ======================================================================== // 인증 테스트 // ======================================================================== /** * 인증 없이 프로필 조회 시 401 반환 */ public function test_show_returns_401_without_authentication(): void { $response = $this->jsonRequest()->getJson('/api/user/profile'); $response->assertStatus(401); } /** * 인증 없이 프로필 수정 시 401 반환 */ public function test_update_returns_401_without_authentication(): void { $response = $this->jsonRequest()->putJson('/api/user/profile', [ 'name' => '새 이름', 'email' => 'newemail@example.com', ]); $response->assertStatus(401); } // ======================================================================== // 프로필 조회 테스트 (show) // ======================================================================== /** * 프로필 조회 성공 */ public function test_show_returns_user_profile(): void { $response = $this->authRequest()->getJson('/api/user/profile'); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 프로필 조회 시 필드 포함 확인 */ public function test_show_returns_profile_specific_fields(): void { $response = $this->authRequest()->getJson('/api/user/profile'); $response->assertStatus(200) ->assertJsonPath('data.email', 'test@example.com') ->assertJsonPath('data.name', '테스트 사용자'); } // ======================================================================== // 프로필 수정 테스트 (update) // ======================================================================== /** * 프로필 수정 성공 */ public function test_update_modifies_profile_successfully(): void { $response = $this->authRequest()->putJson('/api/user/profile', [ 'name' => '수정된 이름', 'email' => 'test@example.com', 'language' => 'ko', 'country' => 'KR', ]); $response->assertStatus(200) ->assertJson(['success' => true]); $this->assertDatabaseHas('users', [ 'id' => $this->user->id, 'name' => '수정된 이름', ]); } /** * 이름 필수 검증 */ public function test_update_validates_name_required(): void { $response = $this->authRequest()->putJson('/api/user/profile', [ 'email' => 'test@example.com', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['name']); } /** * 이메일 고유성 검증 (본인 제외) */ public function test_update_validates_email_unique_except_self(): void { // 다른 사용자 생성 User::factory()->create([ 'email' => 'existing@example.com', ]); $response = $this->authRequest()->putJson('/api/user/profile', [ 'name' => '테스트 사용자', 'email' => 'existing@example.com', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['email']); } /** * 본인 이메일은 유지 가능 */ public function test_update_allows_keeping_own_email(): void { $response = $this->authRequest()->putJson('/api/user/profile', [ 'name' => '새 이름', 'email' => 'test@example.com', // 본인 이메일 'language' => 'ko', 'country' => 'KR', ]); $response->assertStatus(200) ->assertJson(['success' => true]); } /** * 비밀번호 변경 시 현재 비밀번호 필수 */ public function test_update_requires_current_password_for_password_change(): void { $response = $this->authRequest()->putJson('/api/user/profile', [ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'password' => 'newpassword123', 'password_confirmation' => 'newpassword123', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['current_password']); } /** * 비밀번호 확인 검증 */ public function test_update_validates_password_confirmed(): void { $response = $this->authRequest()->putJson('/api/user/profile', [ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'password' => 'newpassword123', 'password_confirmation' => 'differentpassword', 'current_password' => 'password123', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } // ======================================================================== // 언어 설정 테스트 (updateLanguage) // ======================================================================== /** * 언어 설정 변경 성공 */ public function test_update_language_changes_user_language(): void { $response = $this->authRequest()->postJson('/api/user/profile/update-language', [ 'language' => 'en', ]); $response->assertStatus(200) ->assertJson(['success' => true]); $this->assertDatabaseHas('users', [ 'id' => $this->user->id, 'language' => 'en', ]); } /** * 지원하지 않는 언어 코드 검증 */ public function test_update_language_validates_supported_locales(): void { $response = $this->authRequest()->postJson('/api/user/profile/update-language', [ 'language' => 'invalid_language', ]); $response->assertStatus(400); } // ======================================================================== // 활동 로그 테스트 (activityLog) // ======================================================================== /** * 활동 로그 조회 (빈 배열 반환) */ public function test_activity_log_returns_empty_array(): void { $response = $this->authRequest()->getJson('/api/user/profile/activity-log'); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonPath('data.activities', []); } // ======================================================================== // /api/me 엔드포인트 테스트 // ======================================================================== /** * /api/me로 프로필 조회 성공 */ public function test_me_show_returns_user_profile(): void { $response = $this->authRequest()->getJson('/api/me'); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonPath('data.email', 'test@example.com') ->assertJsonPath('data.name', '테스트 사용자'); } /** * /api/me 인증 없이 조회 시 401 반환 */ public function test_me_show_returns_401_without_authentication(): void { $response = $this->jsonRequest()->getJson('/api/me'); $response->assertStatus(401); } /** * /api/me로 프로필 수정 성공 */ public function test_me_update_modifies_profile_successfully(): void { $response = $this->authRequest()->putJson('/api/me', [ 'name' => '수정된 이름', 'email' => 'test@example.com', 'language' => 'ko', 'country' => 'KR', ]); $response->assertStatus(200) ->assertJson(['success' => true]); $this->assertDatabaseHas('users', [ 'id' => $this->user->id, 'name' => '수정된 이름', ]); } /** * /api/me로 추가 필드 수정 성공 (mobile, phone, homepage 등) */ public function test_me_update_modifies_additional_fields(): void { $response = $this->authRequest()->putJson('/api/me', [ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'language' => 'ko', 'country' => 'KR', 'mobile' => '010-1234-5678', 'phone' => '02-123-4567', 'homepage' => 'https://example.com', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '테헤란로 123', 'signature' => '테스트 서명입니다.', 'bio' => '자기소개 내용입니다.', ]); $response->assertStatus(200) ->assertJson(['success' => true]); $this->assertDatabaseHas('users', [ 'id' => $this->user->id, 'mobile' => '010-1234-5678', 'phone' => '02-123-4567', 'homepage' => 'https://example.com', 'signature' => '테스트 서명입니다.', 'bio' => '자기소개 내용입니다.', ]); } /** * mobile 필드 정규식 검증 */ public function test_me_update_validates_mobile_format(): void { $response = $this->authRequest()->putJson('/api/me', [ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'mobile' => 'invalid-mobile-abc', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['mobile']); } /** * phone 필드 정규식 검증 */ public function test_me_update_validates_phone_format(): void { $response = $this->authRequest()->putJson('/api/me', [ 'name' => '테스트 사용자', 'email' => 'test@example.com', 'phone' => 'invalid-phone-abc', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['phone']); } // ======================================================================== // 아바타 업로드/삭제 테스트 // ======================================================================== /** * 아바타 업로드 성공 */ public function test_upload_avatar_successfully(): void { Storage::fake('attachments'); $file = UploadedFile::fake()->image('avatar.jpg', 200, 200); $response = $this->authRequest()->postJson('/api/me/avatar', [ 'avatar' => $file, ]); $response->assertStatus(200) ->assertJson(['success' => true]) ->assertJsonStructure(['data' => ['avatar', 'attachment_id']]); // avatarAttachment 관계로 확인 $this->user->refresh(); $attachment = $this->user->avatarAttachment; $this->assertNotNull($attachment); $this->assertEquals('avatar', $attachment->collection); } /** * 아바타 업로드 - 파일 필수 검증 */ public function test_upload_avatar_requires_file(): void { $response = $this->authRequest()->postJson('/api/me/avatar', []); $response->assertStatus(422) ->assertJsonValidationErrors(['avatar']); } /** * 아바타 업로드 - 이미지 형식만 허용 */ public function test_upload_avatar_only_accepts_images(): void { Storage::fake('attachments'); $file = UploadedFile::fake()->create('document.pdf', 100); $response = $this->authRequest()->postJson('/api/me/avatar', [ 'avatar' => $file, ]); $response->assertStatus(422) ->assertJsonValidationErrors(['avatar']); } /** * 아바타 업로드 - 2MB 초과 시 실패 */ public function test_upload_avatar_validates_max_size(): void { Storage::fake('attachments'); $file = UploadedFile::fake()->image('large-avatar.jpg')->size(3000); $response = $this->authRequest()->postJson('/api/me/avatar', [ 'avatar' => $file, ]); $response->assertStatus(422) ->assertJsonValidationErrors(['avatar']); } /** * 아바타 삭제 성공 */ public function test_delete_avatar_successfully(): void { Storage::fake('attachments'); // Attachment 레코드 생성 (다형성 관계) $attachment = Attachment::factory() ->image() ->attachedTo(User::class, $this->user->id) ->inCollection('avatar') ->create([ 'disk' => 'attachments', 'path' => 'attachments/avatars/test-avatar.jpg', 'source_type' => AttachmentSourceType::Core, ]); Storage::disk('attachments')->put('attachments/avatars/test-avatar.jpg', 'fake content'); $response = $this->authRequest()->deleteJson('/api/me/avatar'); $response->assertStatus(200) ->assertJson(['success' => true]); // Attachment 레코드 삭제 확인 $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]); } /** * 아바타 없을 때 삭제 시도 - 404 반환 */ public function test_delete_avatar_returns_404_when_no_avatar(): void { // avatarAttachment 관계가 없는 상태에서 삭제 시도 $response = $this->authRequest()->deleteJson('/api/me/avatar'); $response->assertStatus(404); } // ======================================================================== // 비밀번호 변경 테스트 (changePassword) // ======================================================================== /** * 비밀번호 변경 성공 */ public function test_change_password_successfully(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', 'password' => 'newpassword456', 'password_confirmation' => 'newpassword456', ]); $response->assertStatus(200) ->assertJson(['success' => true]); // 새 비밀번호로 로그인 가능 확인 $this->user->refresh(); $this->assertTrue(Hash::check('newpassword456', $this->user->password)); } /** * 현재 비밀번호가 틀린 경우 실패 */ public function test_change_password_fails_with_wrong_current_password(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'wrongpassword', 'password' => 'newpassword456', 'password_confirmation' => 'newpassword456', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['current_password']); } /** * 비밀번호 확인이 일치하지 않는 경우 실패 */ public function test_change_password_fails_when_confirmation_does_not_match(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', 'password' => 'newpassword456', 'password_confirmation' => 'differentpassword', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } /** * 새 비밀번호가 8자 미만인 경우 실패 */ public function test_change_password_validates_minimum_length(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', 'password' => 'short', 'password_confirmation' => 'short', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } /** * 현재 비밀번호 필수 검증 */ public function test_change_password_requires_current_password(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'password' => 'newpassword456', 'password_confirmation' => 'newpassword456', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['current_password']); } /** * 새 비밀번호 필수 검증 */ public function test_change_password_requires_new_password(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } /** * 비밀번호 확인 필수 검증 */ public function test_change_password_requires_password_confirmation(): void { $response = $this->authRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', 'password' => 'newpassword456', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['password']); } /** * 인증 없이 비밀번호 변경 시 401 반환 */ public function test_change_password_returns_401_without_authentication(): void { $response = $this->jsonRequest()->putJson('/api/me/password', [ 'current_password' => 'password123', 'password' => 'newpassword456', 'password_confirmation' => 'newpassword456', ]); $response->assertStatus(401); } // ======================================================================== // 회원 탈퇴 테스트 (destroy) // ======================================================================== /** * 회원 탈퇴 성공 */ public function test_withdraw_user_successfully(): void { $originalEmail = $this->user->email; $originalName = $this->user->name; $response = $this->authRequest()->deleteJson('/api/me'); $response->assertStatus(200) ->assertJson(['success' => true]); $this->user->refresh(); // suffix가 추가되었는지 확인 $this->assertStringContainsString('_deleted_', $this->user->email); $this->assertStringContainsString('_탈퇴_', $this->user->name); $this->assertNotEquals($originalEmail, $this->user->email); $this->assertNotEquals($originalName, $this->user->name); // 접미사 형식: 날짜 + 사용자 ID (같은 날 같은 이메일 재탈퇴 충돌 차단, 공개이슈 #112) $this->assertMatchesRegularExpression( '/_deleted_\d{8}_'.$this->user->id.'$/u', $this->user->email, '이메일 접미사에 사용자 ID 가 없으면 같은 날 재탈퇴에서 unique 충돌이 난다' ); // 탈퇴 상태로 변경되었는지 확인 $this->assertEquals('withdrawn', $this->user->status); $this->assertNotNull($this->user->withdrawn_at); } /** * 회원 탈퇴 후 토큰 삭제 확인 */ public function test_withdraw_revokes_all_tokens(): void { // 여러 토큰 생성 $this->user->createToken('token1'); $this->user->createToken('token2'); $this->assertEquals(3, $this->user->tokens()->count()); // setUp의 토큰 포함 $response = $this->authRequest()->deleteJson('/api/me'); $response->assertStatus(200); $this->assertEquals(0, $this->user->tokens()->count()); } /** * 회원 탈퇴 후 아바타 Attachment 삭제 확인 */ public function test_withdraw_deletes_avatar_attachment(): void { Storage::fake('attachments'); // Attachment 레코드 생성 (다형성 관계) $attachment = Attachment::factory() ->image() ->attachedTo(User::class, $this->user->id) ->inCollection('avatar') ->create([ 'disk' => 'attachments', 'path' => 'attachments/avatars/test-avatar.jpg', 'source_type' => AttachmentSourceType::Core, ]); Storage::disk('attachments')->put('attachments/avatars/test-avatar.jpg', 'fake content'); $response = $this->authRequest()->deleteJson('/api/me'); $response->assertStatus(200); // Attachment 레코드 삭제 확인 $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]); // 파일 삭제는 DB 커밋이 확정된 뒤에 수행된다 (공개이슈 #112) Storage::disk('attachments')->assertMissing('attachments/avatars/test-avatar.jpg'); } /** * 슈퍼 관리자는 탈퇴 불가 */ public function test_withdraw_prevents_super_admin(): void { $superAdmin = User::factory()->create([ 'email' => 'superadmin@example.com', 'is_super' => true, ]); $superToken = $superAdmin->createToken('super-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$superToken, 'Accept' => 'application/json', ])->deleteJson('/api/me'); // 관리 anchor 보호는 잘못된 요청이지 서버 오류가 아니다 (공개이슈 #112) $response->assertStatus(422); // 슈퍼 관리자는 변경되지 않음 $superAdmin->refresh(); $this->assertEquals('superadmin@example.com', $superAdmin->email); $this->assertNull($superAdmin->withdrawn_at); } /** * 관리자는 탈퇴 불가 */ public function test_withdraw_prevents_admin(): void { // 관리자 역할 생성 및 할당 $role = Role::factory()->create(['identifier' => 'admin']); $permission = Permission::factory()->create([ 'type' => PermissionType::Admin, ]); $role->permissions()->attach($permission); $admin = User::factory()->create([ 'email' => 'admin@example.com', ]); $admin->roles()->attach($role); $adminToken = $admin->createToken('admin-token')->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$adminToken, 'Accept' => 'application/json', ])->deleteJson('/api/me'); // 관리자 탈퇴 차단은 잘못된 요청이지 서버 오류가 아니다 (공개이슈 #112) $response->assertStatus(422); // 관리자는 변경되지 않음 $admin->refresh(); $this->assertEquals('admin@example.com', $admin->email); $this->assertNull($admin->withdrawn_at); } /** * 탈퇴 시 닉네임에도 suffix 추가 */ public function test_withdraw_adds_suffix_to_nickname(): void { $this->user->update(['nickname' => '테스트닉네임']); $response = $this->authRequest()->deleteJson('/api/me'); $response->assertStatus(200); $this->user->refresh(); $this->assertStringContainsString('_탈퇴', $this->user->nickname); // 컬럼 상한(50자) 안에서 접미사가 붙는다 — 넘기면 저장 자체가 실패한다 $this->assertLessThanOrEqual(User::NICKNAME_MAX_LENGTH, mb_strlen($this->user->nickname)); } /** * 닉네임이 상한에 가까우면 앞을 잘라 접미사를 붙인다 (공개이슈 #112) */ public function test_withdraw_truncates_nickname_at_column_limit(): void { $this->user->update(['nickname' => str_repeat('가', User::NICKNAME_MAX_LENGTH)]); $response = $this->authRequest()->deleteJson('/api/me'); $response->assertStatus(200); $this->user->refresh(); $this->assertSame(User::NICKNAME_MAX_LENGTH, mb_strlen($this->user->nickname)); $this->assertStringEndsWith('_탈퇴', $this->user->nickname); } /** * 탈퇴 인증 없이 요청 시 401 반환 */ public function test_withdraw_returns_401_without_authentication(): void { $response = $this->jsonRequest()->deleteJson('/api/me'); $response->assertStatus(401); } }