diff --git a/backend/database/factories/PlannableItemFactory.php b/backend/database/factories/PlannableItemFactory.php new file mode 100644 index 0000000..8a83d1e --- /dev/null +++ b/backend/database/factories/PlannableItemFactory.php @@ -0,0 +1,62 @@ +faker->randomElement($types); + + return [ + 'trip_id' => Trip::factory(), + 'name' => $this->faker->company(), + 'type' => $type, + 'address' => $this->faker->address(), + 'notes' => $this->faker->sentence(), + 'metadata' => $this->getMetadataForType($type), + ]; + } + + private function getMetadataForType($type) + { + switch ($type) { + case 'hotel': + return [ + 'checkin_time' => '15:00', + 'checkout_time' => '11:00', + 'confirmation_number' => $this->faker->uuid() + ]; + case 'restaurant': + return [ + 'reservation_time' => '19:00', + 'party_size' => $this->faker->numberBetween(2, 6) + ]; + case 'transport': + return [ + 'departure_time' => '10:00', + 'arrival_time' => '14:00', + 'transport_type' => $this->faker->randomElement(['flight', 'train', 'bus']) + ]; + case 'attraction': + return [ + 'opening_hours' => '9:00 AM - 5:00 PM', + 'ticket_price' => '$' . $this->faker->numberBetween(10, 50) + ]; + case 'activity': + return [ + 'duration' => $this->faker->numberBetween(1, 4) . ' hours', + 'meeting_point' => $this->faker->streetAddress() + ]; + default: + return []; + } + } +} \ No newline at end of file diff --git a/backend/tests/Feature/CalendarSlotTest.php b/backend/tests/Feature/CalendarSlotTest.php new file mode 100644 index 0000000..e1b7668 --- /dev/null +++ b/backend/tests/Feature/CalendarSlotTest.php @@ -0,0 +1,230 @@ +user = User::factory()->create(); + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_calendar_slots_are_auto_created_when_trip_is_created() + { + $tripData = [ + 'name' => 'Test Trip', + 'description' => 'Test Description', + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // Check that 3 calendar slots were created (Jan 1, 2, 3) + $this->assertDatabaseCount('calendar_slots', 3); + + $slots = CalendarSlot::where('trip_id', $tripId) + ->orderBy('slot_order') + ->get(); + + $this->assertEquals('Day 1', $slots[0]->name); + $this->assertEquals('2024-01-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals(1, $slots[0]->slot_order); + + $this->assertEquals('Day 2', $slots[1]->name); + $this->assertEquals('2024-01-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals(2, $slots[1]->slot_order); + + $this->assertEquals('Day 3', $slots[2]->name); + $this->assertEquals('2024-01-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals(3, $slots[2]->slot_order); + } + + public function test_calendar_slots_are_updated_when_trip_dates_change() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + // Initially should have 2 slots + $this->assertCount(2, $trip->calendarSlots); + + // Update trip dates + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-02-01', + 'end_date' => '2024-02-04' + ]); + + $response->assertStatus(200); + + // Should now have 4 slots with new dates + $trip->refresh(); + $slots = $trip->calendarSlots()->orderBy('slot_order')->get(); + + $this->assertCount(4, $slots); + $this->assertEquals('2024-02-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-04', $slots[3]->slot_date->format('Y-m-d')); + } + + public function test_can_list_calendar_slots_for_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.name', 'Day 1') + ->assertJsonPath('data.1.name', 'Day 2') + ->assertJsonPath('data.2.name', 'Day 3'); + } + + public function test_can_update_calendar_slot_name() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $trip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Arrival Day' + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Arrival Day'); + + $this->assertDatabaseHas('calendar_slots', [ + 'id' => $slot->id, + 'name' => 'Arrival Day' + ]); + } + + public function test_cannot_access_calendar_slots_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/calendar-slots"); + + $response->assertStatus(403); + } + + public function test_cannot_update_calendar_slot_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $otherTrip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_no_slots_created_for_trip_without_dates() + { + $tripData = [ + 'name' => 'Trip without dates', + 'description' => 'No dates set' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // No slots should be created + $this->assertDatabaseCount('calendar_slots', 0); + + $slots = CalendarSlot::where('trip_id', $tripId)->get(); + $this->assertCount(0, $slots); + } + + public function test_slots_created_when_dates_added_to_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => null, + 'end_date' => null + ]); + + // Initially no slots + $this->assertCount(0, $trip->calendarSlots); + + // Add dates to trip + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-03-01', + 'end_date' => '2024-03-02' + ]); + + $response->assertStatus(200); + + // Now should have 2 slots + $trip->refresh(); + $this->assertCount(2, $trip->calendarSlots); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/PlannableItemTest.php b/backend/tests/Feature/PlannableItemTest.php new file mode 100644 index 0000000..6300edf --- /dev/null +++ b/backend/tests/Feature/PlannableItemTest.php @@ -0,0 +1,188 @@ +user = User::factory()->create(); + $this->trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_can_create_plannable_item() + { + $data = [ + 'name' => 'Eiffel Tower', + 'type' => 'attraction', + 'address' => 'Champ de Mars, Paris', + 'notes' => 'Visit in the morning', + 'metadata' => [ + 'opening_hours' => '9:00 AM - 11:00 PM', + 'ticket_price' => '25 EUR' + ] + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Eiffel Tower') + ->assertJsonPath('data.type', 'attraction') + ->assertJsonPath('data.address', 'Champ de Mars, Paris') + ->assertJsonPath('data.metadata.opening_hours', '9:00 AM - 11:00 PM'); + + $this->assertDatabaseHas('plannable_items', [ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + 'type' => 'attraction' + ]); + } + + public function test_can_list_plannable_items_for_trip() + { + PlannableItem::factory()->count(3)->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$this->trip->id}/plannables"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + public function test_can_update_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Old Name' + ]); + + $updateData = [ + 'name' => 'Updated Name', + 'type' => 'restaurant', + 'notes' => 'Updated notes' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", $updateData); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Updated Name') + ->assertJsonPath('data.type', 'restaurant'); + + $this->assertDatabaseHas('plannable_items', [ + 'id' => $item->id, + 'name' => 'Updated Name', + 'type' => 'restaurant' + ]); + } + + public function test_can_delete_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->deleteJson("/api/plannables/{$item->id}"); + + $response->assertStatus(204); + + $this->assertDatabaseMissing('plannable_items', [ + 'id' => $item->id + ]); + } + + public function test_cannot_access_plannable_items_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/plannables"); + + $response->assertStatus(403); + } + + public function test_cannot_update_plannable_item_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + $item = PlannableItem::factory()->create([ + 'trip_id' => $otherTrip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_validates_required_fields_when_creating_plannable_item() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'type']); + } + + public function test_validates_type_enum_values() + { + $data = [ + 'name' => 'Test Item', + 'type' => 'invalid_type' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['type']); + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..467839b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ -
Loading trip details...
+{error}
+ Back to Dashboard +{trip.description}
+ )} + {tripDatesDisplay} +Calendar view will be implemented here in the future
+{message}
+{message}
+{item.address}
+ )} + {item.notes && ( +{item.notes}
+ )} + + {item.type} + +Loading items...
+No unplanned items
+ ) : ( + unplannedItems.map(item => ( +No items planned for this day
+ ) : ( + items.map(item => ( +