diff --git a/app/Actions/User/CreateUserAction.php b/app/Actions/User/CreateUserAction.php new file mode 100644 index 0000000..07e91a9 --- /dev/null +++ b/app/Actions/User/CreateUserAction.php @@ -0,0 +1,84 @@ + $data['name'], + 'planner_id' => $data['planner_id'], + ]); + + // Create the user + $user = User::create([ + 'name' => $data['name'], + 'planner_id' => $data['planner_id'], + ]); + + if (!$user) { + throw new Exception('User creation returned null'); + } + + Log::info('CreateUserAction: User creation result', [ + 'user_id' => $user->id, + 'name' => $user->name, + 'planner_id' => $user->planner_id, + ]); + + // Verify the user was actually created + $createdUser = User::find($user->id); + if (!$createdUser) { + throw new Exception('User creation did not persist to database'); + } + + if ($createdUser->name !== $data['name']) { + throw new Exception('User creation data mismatch'); + } + + DB::commit(); + + Log::info('CreateUserAction: User successfully created', [ + 'user_id' => $user->id, + 'name' => $user->name, + 'planner_id' => $user->planner_id, + ]); + + return $user; + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('CreateUserAction: User creation failed', [ + 'name' => $data['name'] ?? 'N/A', + 'planner_id' => $data['planner_id'] ?? 'N/A', + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw $e; + } + } +} diff --git a/app/Actions/User/DeleteUserAction.php b/app/Actions/User/DeleteUserAction.php new file mode 100644 index 0000000..40a5b09 --- /dev/null +++ b/app/Actions/User/DeleteUserAction.php @@ -0,0 +1,79 @@ + $user->id, + 'user_name' => $user->name, + 'planner_id' => $user->planner_id, + ]); + + // Check for related data + $userDishCount = $user->userDishes()->count(); + $dishCount = $user->dishes()->count(); + + Log::info('DeleteUserAction: User relationship counts', [ + 'user_id' => $user->id, + 'user_dishes_count' => $userDishCount, + 'dishes_count' => $dishCount, + ]); + + // Store user info before deletion for verification + $userId = $user->id; + $userName = $user->name; + + // Delete the user (cascading deletes should handle related records) + $result = $user->delete(); + + Log::info('DeleteUserAction: Delete result', [ + 'result' => $result, + 'user_id' => $userId, + ]); + + if (! $result) { + throw new Exception('User deletion returned false'); + } + + // Verify the deletion actually happened + $stillExists = User::find($userId); + if ($stillExists) { + throw new Exception('User deletion did not persist to database'); + } + + DB::commit(); + + Log::info('DeleteUserAction: User successfully deleted', [ + 'user_id' => $userId, + 'user_name' => $userName, + ]); + + return true; + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('DeleteUserAction: User deletion failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw $e; + } + } +} diff --git a/app/Livewire/Users/UsersList.php b/app/Livewire/Users/UsersList.php index da1d196..e9b3762 100644 --- a/app/Livewire/Users/UsersList.php +++ b/app/Livewire/Users/UsersList.php @@ -3,6 +3,8 @@ namespace App\Livewire\Users; use App\Models\User; +use App\Actions\User\CreateUserAction; +use App\Actions\User\DeleteUserAction; use App\Actions\User\EditUserAction; use Exception; use Illuminate\Contracts\View\View; @@ -49,15 +51,19 @@ public function store(): void { $this->validate(); - User::create([ - 'name' => $this->name, - 'planner_id' => auth()->id(), - ]); + try { + (new CreateUserAction())->execute([ + 'name' => $this->name, + 'planner_id' => auth()->id(), + ]); - $this->showCreateModal = false; - $this->reset(['name']); + $this->showCreateModal = false; + $this->reset(['name']); - session()->flash('success', 'User created successfully.'); + session()->flash('success', 'User created successfully.'); + } catch (Exception $e) { + session()->flash('error', 'Failed to create user: ' . $e->getMessage()); + } } public function edit(User $user): void @@ -95,11 +101,19 @@ public function confirmDelete(User $user): void public function delete(): void { - $this->deletingUser->delete(); - $this->showDeleteModal = false; - $this->deletingUser = null; + try { + (new DeleteUserAction())->execute($this->deletingUser); + + $this->showDeleteModal = false; + $this->deletingUser = null; - session()->flash('success', 'User deleted successfully.'); + session()->flash('success', 'User deleted successfully.'); + + // Force component to re-render with fresh data + $this->resetPage(); + } catch (Exception $e) { + session()->flash('error', 'Failed to delete user: ' . $e->getMessage()); + } } public function cancel(): void diff --git a/tests/Unit/Actions/User/CreateUserActionTest.php b/tests/Unit/Actions/User/CreateUserActionTest.php new file mode 100644 index 0000000..f899a58 --- /dev/null +++ b/tests/Unit/Actions/User/CreateUserActionTest.php @@ -0,0 +1,238 @@ +action = new CreateUserAction(); + + // Create a planner for testing + $this->planner = Planner::factory()->create(); + } + + public function test_it_can_create_a_user_successfully(): void + { + // Arrange + $userData = [ + 'name' => 'Test User', + 'planner_id' => $this->planner->id, + ]; + + // Act + $user = $this->action->execute($userData); + + // Assert + $this->assertInstanceOf(User::class, $user); + $this->assertEquals('Test User', $user->name); + $this->assertEquals($this->planner->id, $user->planner_id); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => 'Test User', + 'planner_id' => $this->planner->id, + ]); + } + + public function test_it_throws_exception_when_name_is_empty(): void + { + // Arrange + $userData = [ + 'name' => '', + 'planner_id' => $this->planner->id, + ]; + + // Act & Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Name is required'); + + $this->action->execute($userData); + + // Verify no user was created + $this->assertDatabaseMissing('users', [ + 'planner_id' => $this->planner->id, + ]); + } + + public function test_it_throws_exception_when_name_is_missing(): void + { + // Arrange + $userData = [ + 'planner_id' => $this->planner->id, + ]; + + // Act & Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Name is required'); + + $this->action->execute($userData); + } + + public function test_it_throws_exception_when_planner_id_is_empty(): void + { + // Arrange + $userData = [ + 'name' => 'Test User', + 'planner_id' => '', + ]; + + // Act & Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Planner ID is required'); + + $this->action->execute($userData); + } + + public function test_it_throws_exception_when_planner_id_is_missing(): void + { + // Arrange + $userData = [ + 'name' => 'Test User', + ]; + + // Act & Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Planner ID is required'); + + $this->action->execute($userData); + } + + public function test_it_logs_creation_process(): void + { + // Arrange + Log::spy(); + $userData = [ + 'name' => 'Test User', + 'planner_id' => $this->planner->id, + ]; + + // Act + $user = $this->action->execute($userData); + + // Assert + Log::shouldHaveReceived('info') + ->with('CreateUserAction: Starting user creation', [ + 'name' => 'Test User', + 'planner_id' => $this->planner->id, + ]); + + Log::shouldHaveReceived('info') + ->with('CreateUserAction: User successfully created', [ + 'user_id' => $user->id, + 'name' => 'Test User', + 'planner_id' => $this->planner->id, + ]); + } + + public function test_it_handles_database_transaction_rollback_on_failure(): void + { + // Arrange + $userData = [ + 'name' => 'Test User', + 'planner_id' => 999999, // Non-existent planner ID should cause foreign key constraint error + ]; + + // Act & Assert + $this->expectException(\Exception::class); + + try { + $this->action->execute($userData); + } catch (\Exception $e) { + // Verify no user was created (transaction rolled back) + $this->assertDatabaseMissing('users', [ + 'name' => 'Test User', + ]); + throw $e; + } + } + + public function test_it_logs_errors_on_failure(): void + { + // Arrange + Log::spy(); + $userData = [ + 'name' => 'Test User', + 'planner_id' => 999999, // Non-existent planner ID + ]; + + // Act & Assert + try { + $this->action->execute($userData); + } catch (\Exception $e) { + // Expected + } + + // Assert + Log::shouldHaveReceived('error') + ->with('CreateUserAction: User creation failed', \Mockery::on(function ($data) { + return $data['name'] === 'Test User' && + $data['planner_id'] === 999999 && + isset($data['error']) && + isset($data['trace']); + })); + } + + public function test_it_creates_user_with_whitespace_trimmed_name(): void + { + // Arrange + $userData = [ + 'name' => ' Test User ', + 'planner_id' => $this->planner->id, + ]; + + // Act + $user = $this->action->execute($userData); + + // Assert + $this->assertEquals(' Test User ', $user->name); // Should preserve original data as passed + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => ' Test User ', + 'planner_id' => $this->planner->id, + ]); + } + + public function test_it_can_create_multiple_users_with_same_planner(): void + { + // Arrange + $userData1 = [ + 'name' => 'User One', + 'planner_id' => $this->planner->id, + ]; + + $userData2 = [ + 'name' => 'User Two', + 'planner_id' => $this->planner->id, + ]; + + // Act + $user1 = $this->action->execute($userData1); + $user2 = $this->action->execute($userData2); + + // Assert + $this->assertNotEquals($user1->id, $user2->id); + $this->assertEquals($this->planner->id, $user1->planner_id); + $this->assertEquals($this->planner->id, $user2->planner_id); + $this->assertDatabaseHas('users', ['name' => 'User One']); + $this->assertDatabaseHas('users', ['name' => 'User Two']); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Unit/Actions/User/DeleteUserActionTest.php b/tests/Unit/Actions/User/DeleteUserActionTest.php new file mode 100644 index 0000000..1363176 --- /dev/null +++ b/tests/Unit/Actions/User/DeleteUserActionTest.php @@ -0,0 +1,195 @@ +action = new DeleteUserAction(); + + // Create a planner for testing + $this->planner = Planner::factory()->create(); + } + + public function test_it_can_delete_a_user_successfully(): void + { + // Arrange + $user = User::factory()->create([ + 'planner_id' => $this->planner->id, + 'name' => 'Test User' + ]); + $userId = $user->id; + + // Act + $result = $this->action->execute($user); + + // Assert + $this->assertTrue($result); + $this->assertDatabaseMissing('users', ['id' => $userId]); + } + + public function test_it_can_delete_a_user_with_associated_dishes(): void + { + // Arrange + $user = User::factory()->create(['planner_id' => $this->planner->id]); + $dish = Dish::factory()->create(['planner_id' => $this->planner->id]); + + // Associate user with dish + UserDish::create([ + 'user_id' => $user->id, + 'dish_id' => $dish->id + ]); + + $userId = $user->id; + + // Verify the association exists + $this->assertDatabaseHas('user_dishes', [ + 'user_id' => $userId, + 'dish_id' => $dish->id + ]); + + // Act + $result = $this->action->execute($user); + + // Assert + $this->assertTrue($result); + $this->assertDatabaseMissing('users', ['id' => $userId]); + // Verify cascade deletion removed the association + $this->assertDatabaseMissing('user_dishes', ['user_id' => $userId]); + // Verify the dish itself still exists + $this->assertDatabaseHas('dishes', ['id' => $dish->id]); + } + + public function test_it_logs_deletion_process(): void + { + // Arrange + Log::spy(); + $user = User::factory()->create(['planner_id' => $this->planner->id]); + $userId = $user->id; + $userName = $user->name; + + // Act + $this->action->execute($user); + + // Assert + Log::shouldHaveReceived('info') + ->with('DeleteUserAction: Starting user deletion', [ + 'user_id' => $userId, + 'user_name' => $userName, + 'planner_id' => $this->planner->id, + ]); + + Log::shouldHaveReceived('info') + ->with('DeleteUserAction: User successfully deleted', [ + 'user_id' => $userId, + 'user_name' => $userName, + ]); + } + + public function test_it_handles_database_transaction_rollback_on_failure(): void + { + // Arrange + $user = User::factory()->create(['planner_id' => $this->planner->id]); + + // Mock the user to throw an exception during deletion + $mockUser = \Mockery::mock(User::class); + $mockUser->shouldReceive('getAttribute')->with('id')->andReturn($user->id); + $mockUser->shouldReceive('getAttribute')->with('name')->andReturn($user->name); + $mockUser->shouldReceive('getAttribute')->with('planner_id')->andReturn($user->planner_id); + $mockUser->shouldReceive('userDishes')->andReturn($user->userDishes()); + $mockUser->shouldReceive('dishes')->andReturn($user->dishes()); + $mockUser->shouldReceive('delete')->andThrow(new \Exception('Database error')); + + // Act & Assert + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Database error'); + + $this->action->execute($mockUser); + + // Verify original user still exists (transaction rolled back) + $this->assertDatabaseHas('users', ['id' => $user->id]); + } + + public function test_it_throws_exception_when_deletion_returns_false(): void + { + // Arrange + $user = User::factory()->create(['planner_id' => $this->planner->id]); + + // Mock the user to return false on delete + $mockUser = \Mockery::mock(User::class); + $mockUser->shouldReceive('getAttribute')->with('id')->andReturn($user->id); + $mockUser->shouldReceive('getAttribute')->with('name')->andReturn($user->name); + $mockUser->shouldReceive('getAttribute')->with('planner_id')->andReturn($user->planner_id); + $mockUser->shouldReceive('userDishes')->andReturn($user->userDishes()); + $mockUser->shouldReceive('dishes')->andReturn($user->dishes()); + $mockUser->shouldReceive('delete')->andReturn(false); + + // Act & Assert + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User deletion returned false'); + + $this->action->execute($mockUser); + } + + public function test_it_throws_exception_when_deletion_does_not_persist(): void + { + // This test is tricky to implement realistically since we can't easily + // mock the User::find() call in a way that makes sense. + // We'll skip this edge case for now, but in a real scenario you might + // want to test database connection issues, etc. + $this->markTestSkipped('Edge case test - difficult to implement without complex mocking'); + } + + public function test_it_logs_errors_on_failure(): void + { + // Arrange + Log::spy(); + $user = User::factory()->create(['planner_id' => $this->planner->id]); + + // Mock the user to throw an exception during deletion + $mockUser = \Mockery::mock(User::class); + $mockUser->shouldReceive('getAttribute')->with('id')->andReturn($user->id); + $mockUser->shouldReceive('getAttribute')->with('name')->andReturn($user->name); + $mockUser->shouldReceive('getAttribute')->with('planner_id')->andReturn($user->planner_id); + $mockUser->shouldReceive('userDishes')->andReturn($user->userDishes()); + $mockUser->shouldReceive('dishes')->andReturn($user->dishes()); + $mockUser->shouldReceive('delete')->andThrow(new \Exception('Test error')); + + // Act & Assert + try { + $this->action->execute($mockUser); + } catch (\Exception $e) { + // Expected + } + + // Assert + Log::shouldHaveReceived('error') + ->with('DeleteUserAction: User deletion failed', \Mockery::on(function ($data) use ($user) { + return $data['user_id'] === $user->id && + $data['error'] === 'Test error' && + isset($data['trace']); + })); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Unit/Actions/UserActionIntegrationTest.php b/tests/Unit/Actions/UserActionIntegrationTest.php new file mode 100644 index 0000000..d5daa2d --- /dev/null +++ b/tests/Unit/Actions/UserActionIntegrationTest.php @@ -0,0 +1,87 @@ +planner = Planner::factory()->create(); + $this->createAction = new CreateUserAction(); + $this->deleteAction = new DeleteUserAction(); + } + + public function test_complete_user_lifecycle_with_actions(): void + { + // Test creation + $userData = [ + 'name' => 'Integration Test User', + 'planner_id' => $this->planner->id, + ]; + + $user = $this->createAction->execute($userData); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals('Integration Test User', $user->name); + $this->assertEquals($this->planner->id, $user->planner_id); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => 'Integration Test User', + 'planner_id' => $this->planner->id, + ]); + + // Test deletion + $userId = $user->id; + $result = $this->deleteAction->execute($user); + + $this->assertTrue($result); + $this->assertDatabaseMissing('users', ['id' => $userId]); + } + + public function test_creating_and_deleting_user_with_relationships(): void + { + // Create user + $user = $this->createAction->execute([ + 'name' => 'User With Relationships', + 'planner_id' => $this->planner->id, + ]); + + // Create a dish and associate it with the user + $dish = \App\Models\Dish::factory()->create(['planner_id' => $this->planner->id]); + $user->dishes()->attach($dish->id); + + // Verify the relationship exists + $this->assertEquals(1, $user->dishes()->count()); + $this->assertDatabaseHas('user_dishes', [ + 'user_id' => $user->id, + 'dish_id' => $dish->id, + ]); + + // Delete the user + $userId = $user->id; + $result = $this->deleteAction->execute($user); + + // Verify deletion and cascade + $this->assertTrue($result); + $this->assertDatabaseMissing('users', ['id' => $userId]); + $this->assertDatabaseMissing('user_dishes', ['user_id' => $userId]); + + // Dish should still exist + $this->assertDatabaseHas('dishes', ['id' => $dish->id]); + } +} \ No newline at end of file