diff --git a/backend/.gitignore b/backend/.gitignore index b71b1ea..4cbe1a5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -12,6 +12,7 @@ /.vscode /.zed /auth.json +/coverage /node_modules /public/build /public/hot diff --git a/backend/app/Models/Trip.php b/backend/app/Models/Trip.php index 296eb79..c2a7ed9 100644 --- a/backend/app/Models/Trip.php +++ b/backend/app/Models/Trip.php @@ -2,11 +2,13 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Trip extends Model { + use HasFactory; protected $fillable = [ 'name', 'description', diff --git a/backend/database/factories/TripFactory.php b/backend/database/factories/TripFactory.php new file mode 100644 index 0000000..6391d18 --- /dev/null +++ b/backend/database/factories/TripFactory.php @@ -0,0 +1,72 @@ + + */ +class TripFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = Trip::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $startDate = $this->faker->dateTimeBetween('now', '+1 year'); + $endDate = $this->faker->dateTimeBetween($startDate, '+1 year'); + + return [ + 'name' => $this->faker->sentence(3), + 'description' => $this->faker->optional()->paragraph(), + 'start_date' => $this->faker->optional()->date('Y-m-d', $startDate), + 'end_date' => $this->faker->optional()->date('Y-m-d', $endDate), + 'created_by_user_id' => User::factory(), + ]; + } + + /** + * Indicate that the trip has no dates. + */ + public function withoutDates(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => null, + 'end_date' => null, + ]); + } + + /** + * Indicate that the trip is upcoming. + */ + public function upcoming(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => now()->addDays(30)->format('Y-m-d'), + 'end_date' => now()->addDays(37)->format('Y-m-d'), + ]); + } + + /** + * Indicate that the trip is past. + */ + public function past(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => now()->subDays(37)->format('Y-m-d'), + 'end_date' => now()->subDays(30)->format('Y-m-d'), + ]); + } +} \ No newline at end of file diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 5fd5bcf..dbeea8e 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -16,7 +16,20 @@ app + + app/Console + app/Exceptions + app/Http/Kernel.php + app/Providers/BroadcastServiceProvider.php + + + + + + + + diff --git a/backend/tests/Feature/AuthTest.php b/backend/tests/Feature/AuthTest.php new file mode 100644 index 0000000..1f7b860 --- /dev/null +++ b/backend/tests/Feature/AuthTest.php @@ -0,0 +1,457 @@ + 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'user' => [ + 'id', + 'name', + 'email', + 'created_at', + 'updated_at', + ], + 'access_token', + 'token_type', + ] + ]) + ->assertJson([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + 'token_type' => 'Bearer', + ] + ]); + + // Verify user was created in database + $this->assertDatabaseHas('users', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Verify password was hashed + $user = User::where('email', 'john@example.com')->first(); + $this->assertTrue(Hash::check('password123', $user->password)); + } + + /** + * Test registration validation errors. + */ + public function test_registration_validates_required_fields() + { + // Test without any data + $response = $this->postJson('/api/register', []); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Validation errors', + ]) + ->assertJsonPath('data.name', fn($value) => !empty($value)) + ->assertJsonPath('data.email', fn($value) => !empty($value)) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with invalid email + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'invalid-email', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + + // Test with password too short + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => '123', + 'password_confirmation' => '123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with password confirmation mismatch + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'different123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + } + + /** + * Test registration with duplicate email. + */ + public function test_registration_prevents_duplicate_email() + { + // Create an existing user + User::factory()->create([ + 'email' => 'existing@example.com', + ]); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'existing@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + } + + /** + * Test user login with valid credentials. + */ + public function test_user_can_login_with_valid_credentials() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $loginData = [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/login', $loginData); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'user' => [ + 'id', + 'name', + 'email', + 'created_at', + 'updated_at', + ], + 'access_token', + 'token_type', + ] + ]) + ->assertJson([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => [ + 'id' => $user->id, + 'email' => 'john@example.com', + ], + 'token_type' => 'Bearer', + ] + ]); + + // Verify token is valid + $this->assertNotEmpty($response->json('data.access_token')); + } + + /** + * Test login with invalid credentials. + */ + public function test_login_fails_with_invalid_credentials() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('correct-password'), + ]); + + // Test with wrong password - This should return 422 with validation exception + $response = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(422); + + // Test with non-existent email + $response = $this->postJson('/api/login', [ + 'email' => 'nonexistent@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(422); + } + + /** + * Test login validation errors. + */ + public function test_login_validates_required_fields() + { + // Test without any data + $response = $this->postJson('/api/login', []); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Validation errors', + ]) + ->assertJsonPath('data.email', fn($value) => !empty($value)) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with invalid email format + $response = $this->postJson('/api/login', [ + 'email' => 'invalid-email', + 'password' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + } + + /** + * Test authenticated user can access profile. + */ + public function test_authenticated_user_can_access_profile() + { + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/profile'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => [ + 'id' => $user->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ] + ]); + } + + /** + * Test unauthenticated user cannot access profile. + */ + public function test_unauthenticated_user_cannot_access_profile() + { + $response = $this->getJson('/api/profile'); + + $response->assertStatus(401); + } + + /** + * Test authenticated user can logout. + */ + public function test_authenticated_user_can_logout() + { + $user = User::factory()->create(); + $token = $user->createToken('test-token'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token->plainTextToken, + ])->postJson('/api/logout'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Logout successful', + ]); + + // Verify token was deleted + $this->assertDatabaseMissing('personal_access_tokens', [ + 'id' => $token->accessToken->id, + ]); + } + + /** + * Test unauthenticated user cannot logout. + */ + public function test_unauthenticated_user_cannot_logout() + { + $response = $this->postJson('/api/logout'); + + $response->assertStatus(401); + } + + /** + * Test token authentication works. + */ + public function test_token_authentication_works() + { + $user = User::factory()->create(); + $token = $user->createToken('test-token'); + + // Test that the token can be used to access protected routes + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token->plainTextToken, + ])->getJson('/api/user'); + + $response->assertStatus(200) + ->assertJson([ + 'id' => $user->id, + 'email' => $user->email, + ]); + } + + /** + * Test invalid token is rejected. + */ + public function test_invalid_token_is_rejected() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer invalid-token', + ])->getJson('/api/user'); + + $response->assertStatus(401); + } + + /** + * Test registration creates valid tokens. + */ + public function test_registration_creates_valid_token() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + $token = $response->json('data.access_token'); + + // Use the token to access a protected route + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->getJson('/api/profile'); + + $profileResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'email' => 'john@example.com', + ] + ]); + } + + /** + * Test login creates valid tokens. + */ + public function test_login_creates_valid_token() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $loginResponse = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]); + + $token = $loginResponse->json('data.access_token'); + + // Use the token to access a protected route + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->getJson('/api/profile'); + + $profileResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'id' => $user->id, + ] + ]); + } + + /** + * Test registration returns user without password. + */ + public function test_registration_does_not_return_password() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(201) + ->assertJsonMissing(['data.user.password']); + } + + /** + * Test login returns user without password. + */ + public function test_login_does_not_return_password() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(200) + ->assertJsonMissing(['data.user.password']); + } + + /** + * Test profile returns user without password. + */ + public function test_profile_does_not_return_password() + { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/profile'); + + $response->assertStatus(200) + ->assertJsonMissing(['data.password']); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/backend/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/backend/tests/Feature/TripTest.php b/backend/tests/Feature/TripTest.php new file mode 100644 index 0000000..cbf5ba0 --- /dev/null +++ b/backend/tests/Feature/TripTest.php @@ -0,0 +1,422 @@ +user = User::factory()->create(); + } + + /** + * Test that unauthenticated users cannot access trip endpoints. + */ + public function test_unauthenticated_user_cannot_access_trips() + { + $response = $this->getJson('/api/trips'); + $response->assertStatus(401); + + $response = $this->postJson('/api/trips', []); + $response->assertStatus(401); + + $response = $this->putJson('/api/trips/1', []); + $response->assertStatus(401); + + $response = $this->deleteJson('/api/trips/1'); + $response->assertStatus(401); + } + + /** + * Test user can create a trip. + */ + public function test_user_can_create_trip() + { + Sanctum::actingAs($this->user); + + $tripData = [ + 'name' => 'Summer Vacation 2025', + 'description' => 'A wonderful trip to Europe', + 'start_date' => '2025-06-01', + 'end_date' => '2025-06-15', + ]; + + $response = $this->postJson('/api/trips', $tripData); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'id', + 'name', + 'description', + 'start_date', + 'end_date', + 'created_by_user_id', + 'created_at', + 'updated_at', + ]) + ->assertJson([ + 'name' => 'Summer Vacation 2025', + 'description' => 'A wonderful trip to Europe', + 'created_by_user_id' => $this->user->id, + ]); + + $this->assertDatabaseHas('trips', [ + 'name' => 'Summer Vacation 2025', + 'created_by_user_id' => $this->user->id, + ]); + } + + /** + * Test trip creation validation. + */ + public function test_trip_creation_validates_required_fields() + { + Sanctum::actingAs($this->user); + + // Test without name + $response = $this->postJson('/api/trips', [ + 'description' => 'A trip without a name', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + + // Test with empty name + $response = $this->postJson('/api/trips', [ + 'name' => '', + 'description' => 'A trip with empty name', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + + // Test with invalid dates + $response = $this->postJson('/api/trips', [ + 'name' => 'Trip with invalid dates', + 'start_date' => 'not-a-date', + 'end_date' => '2025-13-45', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['start_date', 'end_date']); + + // Test with end date before start date + $response = $this->postJson('/api/trips', [ + 'name' => 'Trip with reversed dates', + 'start_date' => '2025-06-15', + 'end_date' => '2025-06-01', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['end_date']); + } + + /** + * Test user can list their trips. + */ + public function test_user_can_list_their_own_trips() + { + Sanctum::actingAs($this->user); + + // Create some trips for this user + $trips = Trip::factory()->count(3)->create([ + 'created_by_user_id' => $this->user->id, + ]); + + // Create trips for another user (should not be visible) + $otherUser = User::factory()->create(); + Trip::factory()->count(2)->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->getJson('/api/trips'); + + $response->assertStatus(200) + ->assertJsonCount(3) + ->assertJsonStructure([ + '*' => [ + 'id', + 'name', + 'description', + 'start_date', + 'end_date', + 'created_by_user_id', + 'created_at', + 'updated_at', + ] + ]); + + // Verify all returned trips belong to the authenticated user + foreach ($response->json() as $trip) { + $this->assertEquals($this->user->id, $trip['created_by_user_id']); + } + } + + /** + * Test user can view a specific trip. + */ + public function test_user_can_view_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'My Special Trip', + ]); + + $response = $this->getJson("/api/trips/{$trip->id}"); + + $response->assertStatus(200) + ->assertJson([ + 'id' => $trip->id, + 'name' => 'My Special Trip', + 'created_by_user_id' => $this->user->id, + ]); + } + + /** + * Test user cannot view another user's trip. + */ + public function test_user_cannot_view_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->getJson("/api/trips/{$otherTrip->id}"); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + } + + /** + * Test user can update their trip. + */ + public function test_user_can_update_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Original Name', + 'description' => 'Original Description', + ]); + + $updateData = [ + 'name' => 'Updated Trip Name', + 'description' => 'Updated Description', + 'start_date' => '2025-07-01', + 'end_date' => '2025-07-15', + ]; + + $response = $this->putJson("/api/trips/{$trip->id}", $updateData); + + $response->assertStatus(200) + ->assertJson([ + 'id' => $trip->id, + 'name' => 'Updated Trip Name', + 'description' => 'Updated Description', + ]); + + $this->assertDatabaseHas('trips', [ + 'id' => $trip->id, + 'name' => 'Updated Trip Name', + 'description' => 'Updated Description', + ]); + } + + /** + * Test user cannot update another user's trip. + */ + public function test_user_cannot_update_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'name' => 'Other User Trip', + ]); + + $response = $this->putJson("/api/trips/{$otherTrip->id}", [ + 'name' => 'Trying to Update', + ]); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + + // Verify the trip wasn't updated + $this->assertDatabaseHas('trips', [ + 'id' => $otherTrip->id, + 'name' => 'Other User Trip', + ]); + } + + /** + * Test user can delete their trip. + */ + public function test_user_can_delete_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + ]); + + $response = $this->deleteJson("/api/trips/{$trip->id}"); + + // Controller returns 200 with a message + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Trip deleted successfully' + ]); + + $this->assertDatabaseMissing('trips', [ + 'id' => $trip->id, + ]); + } + + /** + * Test user cannot delete another user's trip. + */ + public function test_user_cannot_delete_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->deleteJson("/api/trips/{$otherTrip->id}"); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + + // Verify the trip still exists + $this->assertDatabaseHas('trips', [ + 'id' => $otherTrip->id, + ]); + } + + /** + * Test handling non-existent trip. + */ + public function test_returns_404_for_non_existent_trip() + { + Sanctum::actingAs($this->user); + + $response = $this->getJson('/api/trips/99999'); + $response->assertStatus(404); + + $response = $this->putJson('/api/trips/99999', ['name' => 'Updated']); + $response->assertStatus(404); + + $response = $this->deleteJson('/api/trips/99999'); + $response->assertStatus(404); + } + + /** + * Test trip creation with minimal data. + */ + public function test_user_can_create_trip_with_minimal_data() + { + Sanctum::actingAs($this->user); + + $tripData = [ + 'name' => 'Minimal Trip', + ]; + + $response = $this->postJson('/api/trips', $tripData); + + $response->assertStatus(201) + ->assertJson([ + 'name' => 'Minimal Trip', + 'created_by_user_id' => $this->user->id, + ]) + ->assertJsonStructure([ + 'id', + 'name', + 'created_by_user_id', + 'created_at', + 'updated_at', + ]); + + $this->assertDatabaseHas('trips', [ + 'name' => 'Minimal Trip', + 'created_by_user_id' => $this->user->id, + 'description' => null, + 'start_date' => null, + 'end_date' => null, + ]); + } + + /** + * Test trip name length validation. + */ + public function test_trip_name_length_validation() + { + Sanctum::actingAs($this->user); + + // Test with too long name (assuming max is 255) + $response = $this->postJson('/api/trips', [ + 'name' => str_repeat('a', 256), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } + + /** + * Test trips are returned in correct order. + */ + public function test_trips_are_returned_in_descending_order() + { + Sanctum::actingAs($this->user); + + // Create trips with specific timestamps + $oldTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Old Trip', + 'created_at' => now()->subDays(2), + ]); + + $newTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'New Trip', + 'created_at' => now(), + ]); + + $middleTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Middle Trip', + 'created_at' => now()->subDay(), + ]); + + $response = $this->getJson('/api/trips'); + + $response->assertStatus(200); + + $trips = $response->json(); + + // Verify trips are in descending order (newest first) + $this->assertEquals('New Trip', $trips[0]['name']); + $this->assertEquals('Middle Trip', $trips[1]['name']); + $this->assertEquals('Old Trip', $trips[2]['name']); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/backend/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index 4891c28..3bc831e 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -11,11 +11,21 @@ RUN apk add --no-cache \ unzip \ nodejs \ npm \ - shadow + shadow \ + linux-headers \ + $PHPIZE_DEPS # Install PHP extensions RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd +# Install Xdebug for code coverage +RUN pecl install xdebug && \ + docker-php-ext-enable xdebug + +# Configure Xdebug for coverage (not debugging) +RUN echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + echo "xdebug.start_with_request=no" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer