diff --git a/.gitignore b/.gitignore index 4e253e3ac57529bf3294b411dff3d768d2a756d6..8f1916aff7380e359b8beb4eae63b666ab94981e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ proptrackr-*.tar # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ -/assets/package-lock.json \ No newline at end of file +/assets/package-lock.json +/priv/static/uploads \ No newline at end of file diff --git a/config/dev.exs b/config/dev.exs index 89b49fd53b900a3bd6d2f7ff77db7272d4911898..c86f683dff58c87b2b8da2a9fb608a811fbf8cfc 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -83,3 +83,5 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +config :proptrackr, upload_path: Path.expand("./priv/static/uploads") diff --git a/features/advertisement_creation.feature b/features/advertisement_creation.feature index e507413d30eb2de3076e4344df8d014d72598005..fc0a78ad5f8c5c6b9309e20d44e6d959f32b046a 100644 --- a/features/advertisement_creation.feature +++ b/features/advertisement_creation.feature @@ -1,35 +1,54 @@ Feature: FR-08 Property Advertisement Creation - Scenario: Authenticated user should be able to create a property advertisement + Scenario: Authenticated user should be able to create a property advertisement with photos Given there exists following accounts - | name | surname | birth_date | phone_number | email | password | confirm_password | - | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | + | name | surname | birth_date | phone_number | email | password | confirm_password | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | Given I want to insert the following data - | title | description | type | property_type | location | room_count | area | floor | floor_count | price | - | Apartment | Small apartment | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | + | title | description | type | property_type | location | room_count | area | floor | floor_count | price | + | Apartment | Small apartment | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | And I am logged in And I want to create a new property advertisement And I fill in the property advertisement form with valid data + And I upload 3 valid photos When I click submit Then I should see a success message And I should be redirected to the property advertisement page + And I should see all uploaded photos on the details page + And I should see the first photo on the homepage - Scenario: Authenticated user should be shown an error if the property advertisement form is filled incorrectly + Scenario: Authenticated user cannot create property advertisement without photos Given there exists following accounts - | name | surname | birth_date | phone_number | email | password | confirm_password | - | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | + | name | surname | birth_date | phone_number | email | password | confirm_password | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | Given I want to insert the following data - | title | description | type | property_type | location | room_count | area | floor | floor_count | price | - | Apartment | Small apartment | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | + | title | description | type | property_type | location | room_count | area | floor | floor_count | price | + | Apartment | Small apartment | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | And I am logged in And I want to create a new property advertisement - And I fill in the property advertisement form with invalid data + And I fill in the property advertisement form with valid data + And I don't upload any photos When I click submit - Then I should see error messages + Then I should see "Please upload between 1 and 5 photos" message And I should still see the new property advertisement form - Scenario: Unauthenticated user should not be able to create an advertisement - Given I am not logged in - And I would like to create a new property advertisement - Then I should not see a link to create a new property advertisement - And I should be redirected back to home page when I try to access the link +Scenario: Authenticated user cannot create property advertisement with too many photos + Given there exists following accounts + | name | surname | birth_date | phone_number | email | password | confirm_password | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | + Given I want to insert the following data + | title | description | type | property_type | location | room_count | area | floor | floor_count | price | + | Apartment | Small apartment | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | + And I am logged in + And I want to create a new property advertisement + And I fill in the property advertisement form with valid data + And I upload 6 photos + When I click submit + Then I should see "Please upload between 1 and 5 photos" message + And I should still see the new property advertisement form + +Scenario: Unauthenticated user should not be able to create an advertisement + Given I am not logged in + And I would like to create a new property advertisement + Then I should not see a link to create a new property advertisement + And I should be redirected back to home page when I try to access the link \ No newline at end of file diff --git a/features/contexts/advertisement_creation_context.exs b/features/contexts/advertisement_creation_context.exs index 82f0fff06b8a3993c99e0f91cb3d82a49e1441df..aac77ab14bbdb5132c7d4fc98f94c22ed7e5d418 100644 --- a/features/contexts/advertisement_creation_context.exs +++ b/features/contexts/advertisement_creation_context.exs @@ -4,6 +4,7 @@ defmodule AdvertisementCreationContext do alias PropTrackr.Accounts alias PropTrackr.Repo alias PropTrackr.Accounts.User + alias Properties.Photo scenario_starting_state fn _state -> Ecto.Adapters.SQL.Sandbox.checkout(PropTrackr.Repo) @@ -18,8 +19,8 @@ defmodule AdvertisementCreationContext do end given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} -> - table - |> Enum.map(fn user_details -> User.changeset(%User{}, user_details) end) + table + |> Enum.map(fn user_details -> User.changeset(%User{}, user_details) end) |> Enum.each(fn changeset -> Repo.insert!(changeset) end) existing_user = List.first(table) @@ -66,6 +67,31 @@ defmodule AdvertisementCreationContext do {:ok, state} end + and_ ~r/^I upload 3 valid photos$/, fn state -> + # Find the file input element + file_input = find_element(:id, "property_photos") + + execute_script(""" + (function() { + const input = document.getElementById('property_photos'); + const files = [ + new File(['test content'], 'test_photo_1.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_2.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_3.jpg', { type: 'image/jpeg' }) + ]; + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + """) + + # Give the browser a moment to process the file upload + :timer.sleep(100) + + {:ok, state} + end + when_ ~r/^I click submit$/, fn state -> click({:id, "submit_button"}) {:ok, state} @@ -84,6 +110,161 @@ defmodule AdvertisementCreationContext do {:ok, state} end + and_ ~r/^I should see all uploaded photos on the details page$/, fn state -> + # Wait for images to load + :timer.sleep(500) + + # Find the photo slides container and count the photos + photo_slides = find_all_elements(:css, ".photo-slide") + + # Verify navigation elements if there are multiple photos + if length(photo_slides) > 1 do + prev_button = find_element(:css, "button.prev-photo") + next_button = find_element(:css, "button.next-photo") + thumbnails = find_all_elements(:css, "button.thumbnail") + + # Check that we have the correct number of thumbnails + assert length(thumbnails) == length(photo_slides), + "Expected #{length(photo_slides)} thumbnails but found #{length(thumbnails)}" + end + + # Verify each photo is loaded and visible + Enum.each(photo_slides, fn slide -> + img = find_within_element(slide, :css, "img") + src = attribute_value(img, "src") + + assert String.contains?(src, "/uploads/"), + "Image source should contain /uploads/ but was #{src}" + end) + + # Check counter display + counter = find_element(:css, ".current-photo") + counter_text = visible_text(counter) + assert counter_text == "1", "Counter should start at 1" + + # Verify total number of photos + assert length(photo_slides) == 3, + "Expected 3 photos but found #{length(photo_slides)}" + + {:ok, state} + end + + and_ ~r/^I should see the first photo on the homepage$/, fn state -> + + # Navigate to homepage + navigate_to("/") + :timer.sleep(500) # Wait for page to load + + # Find the property container + property_div = find_element(:css, "#properties > div:first-child") + + # Find the photo container within the property + photo_container = find_within_element(property_div, :css, ".w-48.h-48") + + # Check if there's an image + case find_all_within_element(photo_container, :css, "img") do + [img | _] -> + src = attribute_value(img, "src") + + # Verify image source contains uploads path + assert String.contains?(src, "/uploads/"), + "Image source should contain /uploads/ but was #{src}" + + # Verify image has proper styling + classes = attribute_value(img, "class") + + assert String.contains?(classes, "object-cover"), + "Image should have object-cover class" + assert String.contains?(classes, "rounded"), + "Image should have rounded class" + + # Verify container dimensions + container_classes = attribute_value(photo_container, "class") + + assert String.contains?(container_classes, "w-48"), + "Container should have w-48 class" + assert String.contains?(container_classes, "h-48"), + "Container should have h-48 class" + + [] -> + flunk("No image found in the photo container") + end + + IO.puts("Homepage photo verification completed") + + {:ok, state} + end + + and_ ~r/^I don't upload any photos$/, fn state -> + file_input = find_element(:id, "property_photos") + + execute_script(""" + const input = document.getElementById('property_photos'); + return input.files.length; + """) + |> case do + 0 -> IO.puts("Confirmed: No files selected") + count -> IO.puts("Warning: Found #{count} files when expecting none") + end + + {:ok, state} + end + + then_ ~r/^I should see "(?<argument_one>[^"]+)" message$/, + fn state, %{argument_one: argument_one} -> + + assert visible_in_page?(~r/#{argument_one}/), + "Expected to see message '#{argument_one}' but it was not found on the page" + + {:ok, state} + end + + and_ ~r/^I upload 6 photos$/, fn state -> + file_input = find_element(:id, "property_photos") + + execute_script(""" + (function() { + const input = document.getElementById('property_photos'); + const files = [ + new File(['test content'], 'test_photo_1.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_2.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_3.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_4.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_5.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_6.jpg', { type: 'image/jpeg' }) + ]; + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + """) + + :timer.sleep(100) + {:ok, state} + end + + and_ ~r/^I upload 2 valid photos$/, fn state -> + file_input = find_element(:id, "property_photos") + + execute_script(""" + (function() { + const input = document.getElementById('property_photos'); + const files = [ + new File(['test content'], 'test_photo_1.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_2.jpg', { type: 'image/jpeg' }), + ]; + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + """) + + :timer.sleep(100) + {:ok, state} + end + and_ ~r/^I fill in the property advertisement form with invalid data$/, fn state -> fill_field({:id, "title"}, "wrong") fill_field({:id, "description"}, "invalid_obvs") @@ -144,4 +325,14 @@ defmodule AdvertisementCreationContext do defp select_dropdown(drop_down_id, option) do find_element(:css, "##{drop_down_id} option[value='#{option}']") |> click() end + + defp create_test_photo(index) do + Path.join([ + File.cwd!(), + "test", + "support", + "fixtures", + "photo_#{index}.jpg" + ]) + end end diff --git a/features/contexts/advertisement_list_context.exs b/features/contexts/advertisement_list_context.exs index 3099ff47253ff7340eb92bddbd41bd5f77aa71a1..1e4ef680a6db71669ec711dbb0b0bb2afa200a3d 100644 --- a/features/contexts/advertisement_list_context.exs +++ b/features/contexts/advertisement_list_context.exs @@ -111,6 +111,26 @@ defmodule AdvertisementListContext do fill_field({:id, "floor"}, state[:data][:floor]) fill_field({:id, "floor_count"}, state[:data][:floor_count]) fill_field({:id, "price"}, state[:data][:price]) + + file_input = find_element(:id, "property_photos") + + execute_script(""" + (function() { + const input = document.getElementById('property_photos'); + const files = [ + new File(['test content'], 'test_photo_1.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_2.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_3.jpg', { type: 'image/jpeg' }) + ]; + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + """) + + :timer.sleep(100) + {:ok, state} end diff --git a/features/contexts/my_advertisements_context.exs b/features/contexts/my_advertisements_context.exs index a1cdbf8bfd4c4bc481b4f0b54d0073dbb5c828a9..bbee6664d68beb5e40c7e87cfed1341b95e3f45d 100644 --- a/features/contexts/my_advertisements_context.exs +++ b/features/contexts/my_advertisements_context.exs @@ -47,7 +47,7 @@ defmodule MyAdvertisementsContext do } end - given_ ~r/^I created given properties$/, fn state -> + given_ ~r/^I created given properties with photos$/, fn state -> navigate_to("/") click({:id, "add_advertisement"}) fill_field({:id, "title"}, state[:data][:title]) @@ -60,6 +60,24 @@ defmodule MyAdvertisementsContext do fill_field({:id, "floor"}, state[:data][:floor]) fill_field({:id, "floor_count"}, state[:data][:floor_count]) fill_field({:id, "price"}, state[:data][:price]) + + file_input = find_element(:id, "property_photos") + + execute_script(""" + (function() { + const input = document.getElementById('property_photos'); + const files = [ + new File(['test content'], 'test_photo_1.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_2.jpg', { type: 'image/jpeg' }), + new File(['test content'], 'test_photo_3.jpg', { type: 'image/jpeg' }) + ]; + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + })(); + """) + click({:id, "submit_button"}) property = Repo.get_by(Property, title: state[:data][:title]) {:ok, state |> Map.put(:property, property)} diff --git a/features/my_advertisements.feature b/features/my_advertisements.feature index 5edd5edbbb16ee4ca2665699355bcc3eabd6368d..0cd7af237c3c9c4146edb54623d5580a64642a5f 100644 --- a/features/my_advertisements.feature +++ b/features/my_advertisements.feature @@ -1,14 +1,14 @@ Feature: FR-12 View Own Advertisements - Scenario: Authenticated user should view all its property listings from profile + Scenario: Authenticated user should view all its property listings with photos from profile Given there exists following accounts - | name | surname | birth_date | phone_number | email | password | confirm_password | - | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | + | name | surname | birth_date | phone_number | email | password | confirm_password | + | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | Given I am logged in Given I want to insert the following data - | title | description | type | property_type | location | room_count | area | floor | floor_count | price | - | Apartment | Small apartment in Tartu for rent | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | - Given I created given properties + | title | description | type | property_type | location | room_count | area | floor | floor_count | price | + | Apartment | Small apartment in Tartu for rent | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | + Given I created given properties with photos When I navigate to my profile page And I click on My properties link Then I should be redirected to my properties page @@ -24,14 +24,14 @@ Feature: FR-12 View Own Advertisements Given I want to insert the following data | title | description | type | property_type | location | room_count | area | floor | floor_count | price | | Apartment | Small apartment in Tartu for rent | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | - Given I created given properties + Given I created given properties with photos Given I log out Given I am logged in as "second.user@gmail.com" When I navigate to my profile page And I click on My properties link Then I should be redirected to my properties page And I should not see other user's properties - And I log out + And I log out Scenario: Authenticated user should view details of own advertisement Given there exists following accounts @@ -41,7 +41,7 @@ Feature: FR-12 View Own Advertisements Given I want to insert the following data | title | description | type | property_type | location | room_count | area | floor | floor_count | price | | Apartment | Small apartment in Tartu for rent | rent | apartment | Tartu, Estonia | 1 | 13 | 2 | 4 | 430 | - Given I created given properties + Given I created given properties with photos When I navigate to my profile page And I click on My properties link And I click to View more button diff --git a/lib/proptrackr/properties/photo.ex b/lib/proptrackr/properties/photo.ex new file mode 100644 index 0000000000000000000000000000000000000000..1a9d54b7d2239fa43b47cd31242219b63f07ad17 --- /dev/null +++ b/lib/proptrackr/properties/photo.ex @@ -0,0 +1,17 @@ +defmodule PropTrackr.Properties.Photo do + use Ecto.Schema + import Ecto.Changeset + + schema "photos" do + field :filename, :string + field :order, :integer + belongs_to :property, PropTrackr.Properties.Property + timestamps() + end + + def changeset(photo, attrs) do + photo + |> cast(attrs, [:filename, :order, :property_id]) + |> validate_required([:filename, :property_id]) + end +end diff --git a/lib/proptrackr/properties/property.ex b/lib/proptrackr/properties/property.ex index 942873f9dff09e8fc227e0add5d67df4bd6e22aa..e00404478265c861b4b7d08ee09cb62fc03c8ade 100644 --- a/lib/proptrackr/properties/property.ex +++ b/lib/proptrackr/properties/property.ex @@ -1,7 +1,6 @@ defmodule PropTrackr.Properties.Property do use Ecto.Schema import Ecto.Changeset - alias PropTrackr.Repo schema "properties" do @@ -14,7 +13,7 @@ defmodule PropTrackr.Properties.Property do field :property_type, Ecto.Enum, values: [:apartment, :house, :other] field :state, Ecto.Enum, values: [:available, :reserved, :unavailable], default: :available - # TODO: Image references and upload + has_many :photos, PropTrackr.Properties.Photo field :location, :string diff --git a/lib/proptrackr/uploads.ex b/lib/proptrackr/uploads.ex new file mode 100644 index 0000000000000000000000000000000000000000..c2e74d07e38176f48644d19bcf7081e588f8d7fc --- /dev/null +++ b/lib/proptrackr/uploads.ex @@ -0,0 +1,22 @@ +defmodule PropTrackr.Uploads do + def upload_file(%{filename: filename, path: temp_path}) do + extension = Path.extname(filename) + file_uuid = Ecto.UUID.generate() + new_filename = "#{file_uuid}#{extension}" + + # Define uploads directory + uploads_dir = Application.app_dir(:proptrackr, "priv/static/uploads") + + # Ensure directory exists + File.mkdir_p!(uploads_dir) + + # Build final path + final_path = Path.join(uploads_dir, new_filename) + + # Copy file from temp path to final destination + File.cp!(temp_path, final_path) + + # Return the filename that was saved + new_filename + end +end diff --git a/lib/proptrackr_web/controllers/my_favorites_controller.ex b/lib/proptrackr_web/controllers/my_favorites_controller.ex index 5b0bdae714d2dfcd87a4c2ed0196785a71293e4e..9464bfc86247cba44a68dae3097bb0b397b28f39 100644 --- a/lib/proptrackr_web/controllers/my_favorites_controller.ex +++ b/lib/proptrackr_web/controllers/my_favorites_controller.ex @@ -20,7 +20,7 @@ defmodule PropTrackrWeb.MyFavoritesController do join: u in User, on: p.user_id == u.id, where: f.user_id == ^user_id, - preload: [property: {p, user: u}] + preload: [property: {p, [:user, :photos]}] ) |> Repo.all() diff --git a/lib/proptrackr_web/controllers/my_favorites_html/index.html.heex b/lib/proptrackr_web/controllers/my_favorites_html/index.html.heex index fbc97de4d632066c848fdb199fa952180458cc9d..2a26060ec1303201328b7912b1281691dacba9e9 100644 --- a/lib/proptrackr_web/controllers/my_favorites_html/index.html.heex +++ b/lib/proptrackr_web/controllers/my_favorites_html/index.html.heex @@ -1,45 +1,58 @@ <.header> My Favorite Properties </.header> - <div id="favorites" class="flex flex-col gap-y-4 mt-8"> <%= if @favorites == [] do %> <p>You haven't favorited any properties yet.</p> <% end %> - <%= for favorite <- @favorites do %> <div class="bg-white border-black border rounded px-4 py-2"> - <div class="flex justify-between items-start"> - <h2 class="font-bold"><%= favorite.property.title %></h2> - <button - type="button" - class="favorite-button text-2xl" - data-property-reference={favorite.property.reference} - data-favorited="true" - > - âک… - </button> - </div> - - <p><%= favorite.property.description %></p> - <p class="italic"><%= favorite.property.location %></p> - - <div class="flex flex-row gap-x-2"> - <span><%= favorite.property.price %> €</span> - <span><%= favorite.property.room_count %> rooms</span> - <span><%= favorite.property.area %> m<sup>2</sup></span> - </div> + <div class="flex gap-4"> + <div class="w-48 h-48 flex-shrink-0"> + <%= if first_photo = Enum.at(favorite.property.photos, 0) do %> + <img + src={~p"/uploads/#{first_photo.filename}"} + alt={"Photo of #{favorite.property.title}"} + class="w-full h-full object-cover rounded" + /> + <% else %> + <div class="w-full h-full bg-gray-200 flex items-center justify-center rounded"> + <span class="text-gray-400">No photo</span> + </div> + <% end %> + </div> - <div class="flex flex-row justify-end"> - <.link href={~p"/properties/#{favorite.property.reference}"}> - <.button - type="button" - class="text-white rounded px-4 py-2" - id={"view-#{favorite.property.reference}"} - > - View more - </.button> - </.link> + <div class="flex-grow"> + <div class="flex justify-between items-start"> + <h2 class="font-bold"><%= favorite.property.title %></h2> + <button + type="button" + class="favorite-button text-2xl" + data-property-reference={favorite.property.reference} + data-favorited="true" + > + âک… + </button> + </div> + <p><%= favorite.property.description %></p> + <p class="italic"><%= favorite.property.location %></p> + <div class="flex flex-row gap-x-2"> + <span><%= favorite.property.price %> €</span> + <span><%= favorite.property.room_count %> rooms</span> + <span><%= favorite.property.area %> m<sup>2</sup></span> + </div> + <div class="flex flex-row justify-end"> + <.link href={~p"/properties/#{favorite.property.reference}"}> + <.button + type="button" + class="text-white rounded px-4 py-2" + id={"view-#{favorite.property.reference}"} + > + View more + </.button> + </.link> + </div> + </div> </div> </div> <% end %> diff --git a/lib/proptrackr_web/controllers/my_properties_controller.ex b/lib/proptrackr_web/controllers/my_properties_controller.ex index 2b209fd0d3ed49efdc412a7b7c92c976d032ea58..2c0f1957671633dbe1b895bdc75617a10574f471 100644 --- a/lib/proptrackr_web/controllers/my_properties_controller.ex +++ b/lib/proptrackr_web/controllers/my_properties_controller.ex @@ -11,6 +11,7 @@ defmodule PropTrackrWeb.MyPropertiesController do from p in Property, where: p.user_id == ^user_id, order_by: [asc: p.inserted_at], + preload: [:photos], select: p) render conn, "index.html", properties: properties end diff --git a/lib/proptrackr_web/controllers/my_properties_html/index.html.heex b/lib/proptrackr_web/controllers/my_properties_html/index.html.heex index fb79e067b722b25d917be96561f35e85681831b5..60ada05ad49640bc4ec779f61e41217e99ebdc9b 100644 --- a/lib/proptrackr_web/controllers/my_properties_html/index.html.heex +++ b/lib/proptrackr_web/controllers/my_properties_html/index.html.heex @@ -1,25 +1,45 @@ <.header> My Properties </.header> - <div id="properties" class="flex flex-col gap-y-4 mt-20"> <%= for property <- @properties do %> <div class="bg-white border-black border rounded px-4 py-2"> - <h2 class="font-bold"><%= property.title %></h2> - <p><%= property.description %></p> - - <p class="italic"><%= property.location %></p> - - <div class="flex flex-row gap-x-2"> - <span><%= property.price %> €</span> - <span><%= property.room_count %> rooms</span> - <span><%= property.area %> m<sup>2</sup></span> - </div> + <div class="flex gap-4"> + <div class="w-48 h-48 flex-shrink-0"> + <%= if first_photo = Enum.at(property.photos, 0) do %> + <img + src={~p"/uploads/#{first_photo.filename}"} + alt={"Photo of #{property.title}"} + class="w-full h-full object-cover rounded" + /> + <% else %> + <div class="w-full h-full bg-gray-200 flex items-center justify-center rounded"> + <span class="text-gray-400">No photo</span> + </div> + <% end %> + </div> - <div class="flex flex-row justify-end"> - <.link href={~p"/properties/#{property.reference}"}> - <.button type="button" class="text-white rounded px-4 py-2" id={ "edit-#{property.reference}" }>View more</.button> - </.link> + <div class="flex-grow"> + <h2 class="font-bold"><%= property.title %></h2> + <p><%= property.description %></p> + <p class="italic"><%= property.location %></p> + <div class="flex flex-row gap-x-2"> + <span><%= property.price %> €</span> + <span><%= property.room_count %> rooms</span> + <span><%= property.area %> m<sup>2</sup></span> + </div> + <div class="flex flex-row justify-end"> + <.link href={~p"/properties/#{property.reference}"}> + <.button + type="button" + class="text-white rounded px-4 py-2" + id={"edit-#{property.reference}"} + > + View more + </.button> + </.link> + </div> + </div> </div> </div> <% end %> diff --git a/lib/proptrackr_web/controllers/properties_controller.ex b/lib/proptrackr_web/controllers/properties_controller.ex index 8ae3be252a420d2bec8ff89a42a0a8e4263c79c4..33c03a6fb8414fe9c34746ac73421d9d8ea6f239 100644 --- a/lib/proptrackr_web/controllers/properties_controller.ex +++ b/lib/proptrackr_web/controllers/properties_controller.ex @@ -3,8 +3,9 @@ defmodule PropTrackrWeb.PropertiesController do import Ecto.Query, only: [from: 2] alias PropTrackr.Repo - alias PropTrackr.Properties.Property + alias PropTrackr.Properties.{Property, Photo} alias PropTrackr.Favorites.Favorite + alias PropTrackr.Uploads def index(conn, _params) do @@ -12,6 +13,8 @@ defmodule PropTrackrWeb.PropertiesController do from p in Property, where: p.state == :available, order_by: [asc: p.inserted_at], + preload: [:photos], + # preload: [photos: ^from(ph in Photo, order_by: [asc: ph.order])], select: p) favorites = case conn.assigns.current_user do @@ -34,7 +37,7 @@ defmodule PropTrackrWeb.PropertiesController do property = Repo.one( from p in Property, where: p.reference == ^reference, - preload: [:user], + preload: [:user, :photos], select: p ) @@ -74,32 +77,94 @@ defmodule PropTrackrWeb.PropertiesController do render conn, "new.html", changeset: changeset end - def create(conn, %{"property" => property}) do - current_user = conn.assigns.current_user - if current_user == nil do +def create(conn, %{"property" => property_params}) do + current_user = conn.assigns.current_user + + if current_user == nil do + conn + |> put_flash(:error, "You are not logged in!") + |> redirect(to: "/") + else + uploaded_files = Map.get(property_params, "photos", []) + + # Add reference to property params + property_params = property_params |> Map.put("reference", Ecto.UUID.generate()) + + # Clean and convert property params + property_params_with_atoms = + property_params + |> Map.delete("photos") + |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) + |> Enum.into(%{}) + + # Build property association with user + property_assoc = Ecto.build_assoc(current_user, :properties, property_params_with_atoms) + property_changeset = Property.changeset(property_assoc, property_params_with_atoms) + + if length(uploaded_files) < 1 or length(uploaded_files) > 5 do conn - |> put_flash(:error, "You are not logged in!") - |> redirect(to: "/") + |> put_flash(:error, "Please upload between 1 and 5 photos") + |> render("new.html", changeset: property_changeset) else - property = property |> Map.put("reference", Ecto.UUID.generate()) - property_assoc = Ecto.build_assoc(current_user, :properties, Enum.map(property, fn {key, value} -> {String.to_atom(key), value} end)) - property_changeset = Property.changeset(property_assoc, property) - - # Note: IDK this does not work properly - # |> Ecto.Changeset.put_change(:reference, Ecto.UUID.generate()) + Ecto.Multi.new() + |> Ecto.Multi.insert(:property, property_changeset) + |> Ecto.Multi.run(:photos, fn repo, %{property: property} -> + + results = + uploaded_files + |> Enum.with_index(1) + |> Enum.map(fn {upload, index} -> + case upload do + %Plug.Upload{} = file -> + # Save file and create photo record + filename = Uploads.upload_file(file) + + photo_changeset = Photo.changeset(%Photo{}, %{ + filename: filename, + order: index, + property_id: property.id + }) + + case repo.insert(photo_changeset) do + {:ok, photo} -> + IO.puts("Successfully created photo with ID: #{photo.id}") + {:ok, photo} + {:error, reason} = error -> + IO.puts("Failed to create photo: #{inspect(reason)}") + error + end + _ -> {:error, "Invalid file upload"} + end + end) - case Repo.insert(property_changeset) do - {:ok, property} -> + case Enum.split_with(results, &(elem(&1, 0) == :ok)) do + {successful, []} -> + {:ok, Enum.map(successful, &elem(&1, 1))} + {_, failed} -> + {:error, "Failed to create some photos: #{inspect(failed)}"} + end + end) + |> Repo.transaction() + |> case do + {:ok, %{property: property, photos: photos}} -> conn |> put_flash(:info, "Property created successfully!") |> redirect(to: ~p"/properties/#{property.reference}") - {:error, changeset} -> - render conn, "new.html", changeset: changeset + {:error, :property, changeset, _} -> + conn + |> put_flash(:error, "Failed to create property") + |> render("new.html", changeset: changeset) + + {:error, :photos, error, _} -> + conn + |> put_flash(:error, "Failed to upload photos: #{inspect(error)}") + |> render("new.html", changeset: property_changeset) end end end +end def edit(conn, %{"reference" => reference}) do property = Repo.get_by(Property, reference: reference) @@ -121,6 +186,7 @@ defmodule PropTrackrWeb.PropertiesController do end end + @spec update(Plug.Conn.t(), map()) :: Plug.Conn.t() def update(conn, %{"reference" => reference, "property" => property_params}) do property = Repo.get_by(Property, reference: reference) @@ -155,7 +221,8 @@ defmodule PropTrackrWeb.PropertiesController do |> put_flash(:error, "You are not logged in!") |> redirect(to: "/") else - property = Repo.get_by(Property, reference: reference) + # property = Repo.get_by(Property, reference: reference) + property = Repo.get_by(Property, reference: reference) |> Repo.preload(:photos) case property do nil -> @@ -165,6 +232,12 @@ defmodule PropTrackrWeb.PropertiesController do property -> if conn.assigns.current_user.id == property.user_id do + Enum.each(property.photos, fn photo -> + uploads_dir = Application.app_dir(:proptrackr, "priv/static/uploads") + file_path = Path.join(uploads_dir, photo.filename) + File.rm(file_path) + end) + case Repo.delete(property) do {:ok, _deleted_property} -> conn diff --git a/lib/proptrackr_web/controllers/properties_html/index.html.heex b/lib/proptrackr_web/controllers/properties_html/index.html.heex index ea5f9d655034ff397c7e13627c7b321fbac895a7..43f687abaa7c1e30aac5e5dda12143f4c902b305 100644 --- a/lib/proptrackr_web/controllers/properties_html/index.html.heex +++ b/lib/proptrackr_web/controllers/properties_html/index.html.heex @@ -15,36 +15,59 @@ <%= if @properties == [] do %> <p>No advertisements at the moment</p> <% end %> - <div id="properties" class="flex flex-col gap-y-4 mt-20"> <%= for property <- @properties do %> <div class="bg-white border-black border rounded px-4 py-2"> - <div class="flex justify-between items-start"> - <h2 class="font-bold"><%= property.title %></h2> - <%= if @conn.assigns[:current_user] && @conn.assigns.current_user.id != property.user_id do %> - <button - type="button" - class="favorite-button text-2xl" - data-property-reference={property.reference} - data-favorited={if property.id in @favorites, do: "true", else: "false"} - > - <%= if property.id in @favorites, do: "âک…", else: "âک†" %> - </button> - <% end %> - </div> - <p><%= property.description %></p> - <p class="italic"><%= property.location %></p> - <div class="flex flex-row gap-x-2"> - <span><%= property.price %> €</span> - <span><%= property.room_count %> rooms</span> - <span><%= property.area %> m<sup>2</sup></span> - </div> - <div class="flex flex-row justify-end"> - <.link href={~p"/properties/#{property.reference}"}> - <.button type="button" class="text-white rounded px-4 py-2" id={ "edit-#{property.reference}" } data-title={property.title}>View more</.button> - </.link> + <div class="flex gap-4"> + <div class="w-48 h-48 flex-shrink-0"> + <%= if first_photo = Enum.at(property.photos, 0) do %> + <img + src={~p"/uploads/#{first_photo.filename}"} + alt={"Photo of #{property.title}"} + class="w-full h-full object-cover rounded" + /> + <% else %> + <div class="w-full h-full bg-gray-200 flex items-center justify-center rounded"> + <span class="text-gray-400">No photo</span> + </div> + <% end %> + </div> + + <div class="flex-grow"> + <div class="flex justify-between items-start"> + <h2 class="font-bold"><%= property.title %></h2> + <%= if @conn.assigns[:current_user] && @conn.assigns.current_user.id != property.user_id do %> + <button + type="button" + class="favorite-button text-2xl" + data-property-reference={property.reference} + data-favorited={if property.id in @favorites, do: "true", else: "false"} + > + <%= if property.id in @favorites, do: "âک…", else: "âک†" %> + </button> + <% end %> + </div> + <p><%= property.description %></p> + <p class="italic"><%= property.location %></p> + <div class="flex flex-row gap-x-2"> + <span><%= property.price %> €</span> + <span><%= property.room_count %> rooms</span> + <span><%= property.area %> m<sup>2</sup></span> + </div> + <div class="flex flex-row justify-end"> + <.link href={~p"/properties/#{property.reference}"}> + <.button + type="button" + class="text-white rounded px-4 py-2" + id={"edit-#{property.reference}"} + data-title={property.title} + > + View more + </.button> + </.link> + </div> + </div> </div> </div> <% end %> </div> - diff --git a/lib/proptrackr_web/controllers/properties_html/new.html.heex b/lib/proptrackr_web/controllers/properties_html/new.html.heex index f6f95555bbdfe5dacba543b4101a6b2eafd254af..ee6003a5e4dcca3a47cdb640e460ee69e0ffdab3 100644 --- a/lib/proptrackr_web/controllers/properties_html/new.html.heex +++ b/lib/proptrackr_web/controllers/properties_html/new.html.heex @@ -2,24 +2,71 @@ Add a new property listing </.header> -<.simple_form :let={f} for={@changeset} method="POST" action="/properties"> +<.simple_form :let={f} for={@changeset} method="POST" action="/properties" multipart> <.input field={f[:title]} id="title" type="text" label="Title" required /> <.input field={f[:description]} id="description" type="textarea" label="Description" required /> - <.input field={f[:type]} id="type" type="select" label="Advertisement type" options={[{"Rent", :rent}, {"Sell", :sell}]} requried /> - <.input field={f[:property_type]} id="property_type" type="select" label="Property type" options={[{"Apartment", :apartment}, {"House", :house}, {"Other", :other}]} required /> + <.input + field={f[:type]} + id="type" + type="select" + label="Advertisement type" + options={[{"Rent", :rent}, {"Sell", :sell}]} + requried + /> + <.input + field={f[:property_type]} + id="property_type" + type="select" + label="Property type" + options={[{"Apartment", :apartment}, {"House", :house}, {"Other", :other}]} + required + /> - <.input field={f[:location]} id="location" type="text" label="Location" placeholder="Tartu, Estonia" required /> + <.input + field={f[:location]} + id="location" + type="text" + label="Location" + placeholder="Tartu, Estonia" + required + /> <.input field={f[:area]} id="area" type="text" label="Area (m^2)" required /> - <.input field={f[:room_count]} id="room_count" type="number" label="Room count" min={1} required /> + <.input + field={f[:room_count]} + id="room_count" + type="number" + label="Room count" + min={1} + required + /> <.input field={f[:floor]} id="floor" type="number" label="Floor" min={1} required /> - <.input field={f[:floor_count]} id="floor_count" type="number" label="Floor count" min={0} required /> + <.input + field={f[:floor_count]} + id="floor_count" + type="number" + label="Floor count" + min={0} + required + /> <.input field={f[:price]} id="price" type="text" label="Price" required /> + <div phx-feedback-for="property[photos]"> + <label for="property_photos" class="block text-sm font-medium text-gray-700"> + Photos (Upload 1-5 images) + </label> + <input + type="file" + name="property[photos][]" + id="property_photos" + multiple + accept="image/*" + class="mt-2 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-700 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-400/10 sm:text-sm sm:leading-6" + /> + </div> + <:actions> - <.button id="submit_button">Create advertisement</.button> + <.button id="submit_button">Save Product</.button> </:actions> </.simple_form> - - diff --git a/lib/proptrackr_web/controllers/properties_html/show.html.heex b/lib/proptrackr_web/controllers/properties_html/show.html.heex index df0e48602088abfc5872f88bc0db65279b598385..2e2fd10fdeec55eb86c51fd625a1eeace894c396 100644 --- a/lib/proptrackr_web/controllers/properties_html/show.html.heex +++ b/lib/proptrackr_web/controllers/properties_html/show.html.heex @@ -40,6 +40,73 @@ </:actions> </.header> +<div class="mt-8 bg-white shadow rounded-lg p-6"> + <h3 class="text-lg font-medium text-gray-900 mb-4">Property Photos</h3> + + <div class="relative"> + + <div class="w-full h-[500px] relative overflow-hidden rounded-lg"> + <%= if length(@property.photos) > 0 do %> + <%= for {photo, index} <- Enum.with_index(@property.photos) do %> + <div + class="photo-slide absolute w-full h-full transition-transform duration-300" + data-index={index} + style="transform: translateX(#{index * 100}%)" + > + <img + src={~p"/uploads/#{photo.filename}"} + alt={"Photo #{index + 1} of #{@property.title}"} + class="w-full h-full object-cover" + /> + </div> + <% end %> + <% else %> + <div class="w-full h-full bg-gray-200 flex items-center justify-center"> + <span class="text-gray-400">No photos available</span> + </div> + <% end %> + </div> + + <%= if length(@property.photos) > 1 do %> + <button + class="prev-photo absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/75 text-white w-10 h-10 rounded-full flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100" + aria-label="Previous photo" + > + â†گ + </button> + <button + class="next-photo absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/75 text-white w-10 h-10 rounded-full flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100" + aria-label="Next photo" + > + → + </button> + <% end %> + + <%= if length(@property.photos) > 0 do %> + <div class="absolute bottom-4 right-4 bg-black/50 text-white px-3 py-1 rounded-full text-sm"> + <span class="current-photo">1</span>/<span><%= length(@property.photos) %></span> + </div> + <% end %> + </div> + + <%= if length(@property.photos) > 1 do %> + <div class="mt-4 flex gap-2 overflow-x-auto pb-2"> + <%= for {photo, index} <- Enum.with_index(@property.photos) do %> + <button + class="thumbnail w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden focus:ring-2 focus:ring-blue-500" + data-index={index} + > + <img + src={~p"/uploads/#{photo.filename}"} + alt={"Thumbnail #{index + 1}"} + class="w-full h-full object-cover" + /> + </button> + <% end %> + </div> + <% end %> +</div> + <div class="mt-8 space-y-8"> <div class="bg-white shadow rounded-lg p-6"> <dl class="grid grid-cols-1 md:grid-cols-2 gap-4"> @@ -81,19 +148,47 @@ data-property-type={@property.type} > Update Status - <svg class="-mr-3 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> + <svg + class="-mr-3 ml-2 h-5 w-5" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + > + <path + fill-rule="evenodd" + d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" + clip-rule="evenodd" + /> </svg> </button> </div> <div class="status-dropdown-menu hidden origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-100 focus:outline-none" - role="menu" aria-orientation="vertical" aria-labelledby="status-menu-button" tabindex="-1" + role="menu" + aria-orientation="vertical" + aria-labelledby="status-menu-button" + tabindex="-1" > <div class="py-1" role="none"> - <button class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" role="menuitem" data-status="available">Available</button> - <button class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" role="menuitem" data-status="reserved">Reserved</button> - <button class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" role="menuitem" data-status="unavailable"> + <button + class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" + role="menuitem" + data-status="available" + > + Available + </button> + <button + class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" + role="menuitem" + data-status="reserved" + > + Reserved + </button> + <button + class="status-option text-gray-700 block px-4 py-2 text-sm w-full text-left hover:bg-gray-100" + role="menuitem" + data-status="unavailable" + > <%= if @property.type == :rent, do: "Rented", else: "Sold" %> </button> </div> @@ -155,11 +250,13 @@ <div class="flex items-center justify-between mt-4 similar-property" data-what="similar-property" - data-price={property.price}> - + data-price={property.price} + > <div> <p class="text-sm text-gray-600"><%= property.title %></p> - <p class="text-sm text-gray-600"><%= :erlang.float_to_binary(property.price, decimals: 2) %>€</p> + <p class="text-sm text-gray-600"> + <%= :erlang.float_to_binary(property.price, decimals: 2) %>€ + </p> <p class="text-sm text-gray-600"><%= property.location %></p> </div> <a @@ -175,3 +272,42 @@ </div> </div> </div> + +<script> + document.addEventListener('DOMContentLoaded', function() { + const slides = document.querySelectorAll('.photo-slide'); + const prevButton = document.querySelector('.prev-photo'); + const nextButton = document.querySelector('.next-photo'); + const thumbnails = document.querySelectorAll('.thumbnail'); + const currentPhotoSpan = document.querySelector('.current-photo'); + let currentIndex = 0; + + function updateSlides(newIndex) { + slides.forEach((slide, index) => { + slide.style.transform = `translateX(${(index - newIndex) * 100}%)`; + }); + currentIndex = newIndex; + currentPhotoSpan.textContent = (currentIndex + 1).toString(); + } + + if (prevButton) { + prevButton.addEventListener('click', () => { + const newIndex = currentIndex === 0 ? slides.length - 1 : currentIndex - 1; + updateSlides(newIndex); + }); + } + + if (nextButton) { + nextButton.addEventListener('click', () => { + const newIndex = currentIndex === slides.length - 1 ? 0 : currentIndex + 1; + updateSlides(newIndex); + }); + } + + thumbnails.forEach((thumbnail, index) => { + thumbnail.addEventListener('click', () => { + updateSlides(index); + }); + }); + }); +</script> diff --git a/lib/proptrackr_web/endpoint.ex b/lib/proptrackr_web/endpoint.ex index cf91fc7362e62067857f60484ab802dd57a1beb4..284c270bef78d6a567c0f677a14ef5ed6a0df027 100644 --- a/lib/proptrackr_web/endpoint.ex +++ b/lib/proptrackr_web/endpoint.ex @@ -25,6 +25,11 @@ defmodule PropTrackrWeb.Endpoint do gzip: false, only: PropTrackrWeb.static_paths() + plug Plug.Static, + at: "/uploads", + from: Path.expand("./priv/static/uploads"), + gzip: false + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do @@ -45,6 +50,7 @@ defmodule PropTrackrWeb.Endpoint do parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() + # length: 100_000_000 TO BE CLARIFIED plug Plug.MethodOverride plug Plug.Head diff --git a/priv/repo/migrations/20241128154230_photos.exs b/priv/repo/migrations/20241128154230_photos.exs new file mode 100644 index 0000000000000000000000000000000000000000..fa91a147617300e39f7a79f00d9a6e404b86549f --- /dev/null +++ b/priv/repo/migrations/20241128154230_photos.exs @@ -0,0 +1,14 @@ +defmodule PropTrackr.Repo.Migrations.Photos do + use Ecto.Migration + + def change do + create table(:photos) do + add :filename, :string, null: false + add :order, :integer + add :property_id, references(:properties, on_delete: :delete_all) + timestamps() + end + + create index(:photos, [:property_id]) + end +end diff --git a/test/proptrackr_web/controllers/properties_controller_test.exs b/test/proptrackr_web/controllers/properties_controller_test.exs index 71a1456cdbe1caffdc961fb14e5fc8f63d36953b..0184f18c1adeeec79fa312bed6ffdeac5643dfa3 100644 --- a/test/proptrackr_web/controllers/properties_controller_test.exs +++ b/test/proptrackr_web/controllers/properties_controller_test.exs @@ -44,6 +44,18 @@ defmodule PropTrackrWeb.PropertiesControllerTest do } setup do + uploads_dir = Application.app_dir(:proptrackr, "priv/static/uploads") + File.mkdir_p!(uploads_dir) + + test_image_path = Path.join(uploads_dir, "test_image.jpg") + File.write!(test_image_path, "fake image content") + + test_photo = %Plug.Upload{ + path: test_image_path, + filename: "test_image.jpg", + content_type: "image/jpeg" + } + user = %User{ name: "Test", surname: "User", @@ -68,13 +80,19 @@ defmodule PropTrackrWeb.PropertiesControllerTest do } other_user = Repo.insert!(other_user) - {:ok, %{user: user, other_user: other_user}} + on_exit(fn -> + File.rm_rf!(uploads_dir) + end) + + {:ok, %{user: user, other_user: other_user, test_photo: test_photo}} end - test "Authenticated user should be able to create a new property advertisement with valid data", %{ conn: conn, user: user } do + test "Authenticated user should be able to create a new property advertisement with valid data", %{ conn: conn, user: user, test_photo: test_photo } do conn = conn |> setup_session(user) - conn = conn |> post("/properties", property: @valid_data) + + property_data = Map.put(@valid_data, "photos", [test_photo]) + conn = conn |> post("/properties", property: property_data) uuidv4_regex = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ path = String.split(redirected_to(conn), "/") @@ -86,7 +104,7 @@ defmodule PropTrackrWeb.PropertiesControllerTest do conn = get conn, redirected_to(conn) assert html_response(conn, 200) =~ "Property created successfully!" - advertisement = Repo.get_by!(Property, reference: advertisement_reference) + advertisement = Repo.get_by!(Property, reference: advertisement_reference) |> Repo.preload(:photos) assert advertisement.title == @valid_data[:title] assert advertisement.description == @valid_data[:description] assert advertisement.type == String.to_atom(@valid_data[:type]) @@ -98,59 +116,122 @@ defmodule PropTrackrWeb.PropertiesControllerTest do assert advertisement.floor_count == @valid_data[:floor_count] assert advertisement.price == @valid_data[:price] assert advertisement.user_id == user.id # check if right user is connected to advertisement + + assert length(advertisement.photos) == 1 + photo = List.first(advertisement.photos) + assert photo.order == 1 + assert String.ends_with?(photo.filename, ".jpg") end - test "Authenticated user should not be able to create an advertisement with invalid data", %{ conn: conn, user: user } do + test "Authenticated user should not be able to create an advertisement with invalid data", %{ + conn: conn, + user: user, + test_photo: test_photo + } do conn = conn |> setup_session(user) - conn = conn |> post("/properties", property: @invalid_data) - assert html_response(conn, 200) =~ "must be greater than 0" + invalid_data_with_photo = Map.put(@invalid_data, "photos", [test_photo]) + + conn = conn |> post("/properties", property: invalid_data_with_photo) + + response = html_response(conn, 200) + + assert response =~ "must be greater than 0" end - test "Authenticated user should not be able to create an advertisement with invalid floor values", %{ conn: conn, user: user } do + + test "Authenticated user should not be able to create an advertisement with invalid floor values", %{ + conn: conn, + user: user, + test_photo: test_photo + } do conn = conn |> setup_session(user) - data = %{ @invalid_data | floor: 2, floor_count: 1 } - conn = conn |> post("/properties", property: data) + valid_base_data = %{ + title: "Valid Title Here", + description: "This is a valid description that is definitely long enough", + type: "rent", + property_type: "apartment", + location: "Tartu, Estonia", + room_count: 2, + area: 50.0, + price: 500.0, + floor: 2, + floor_count: 1 + } + + data_with_photo = Map.put(valid_base_data, "photos", [test_photo]) + + conn = conn |> post("/properties", property: data_with_photo) assert html_response(conn, 200) =~ "Floor count must be greater than floor" end - test "Authenticated user should not be able to create an advertisement with invalid floor count data on a house listing", %{ conn: conn, user: user } do + + test "Authenticated user should not be able to create an advertisement with invalid floor count data on a house listing", %{ conn: conn, user: user, test_photo: test_photo } do conn = conn |> setup_session(user) data = %{ @invalid_data | property_type: :house, floor: 2, floor_count: 1 } - conn = conn |> post("/properties", property: data) + + data_with_photo = Map.put(data, "photos", [test_photo]) + conn = conn |> post("/properties", property: data_with_photo) # Just assert that a redirect happened, don't care about data since it does not matter here assert redirected_to(conn) != "/properties" end - test "Unauthenticated user should be redirected to the homepage", %{ conn: conn, user: user } do - conn = post(conn, "/properties", property: @valid_data) + test "Unauthenticated user should be redirected to the homepage", %{ + conn: conn, + test_photo: test_photo + } do + property_data = Map.put(@valid_data, "photos", [test_photo]) + conn = post(conn, "/properties", property: property_data) + assert redirected_to(conn) == "/" + + conn = get(conn, "/") + assert html_response(conn, 200) =~ "You are not logged in!" end - test "Unauthenticated user should see a message when there are no advertisements", %{ conn: conn } do - conn = get conn, "/" + test "Unauthenticated user should see a message when there are no advertisements", %{ + conn: conn + } do + conn = get(conn, "/") assert html_response(conn, 200) =~ "No advertisements at the moment" end - test "Unauthenticated user should see a list of all properties", %{ conn: conn, user: user } do + test "Unauthenticated user should see a list of all properties", %{ + conn: conn, + user: user, + test_photo: test_photo + } do conn = conn |> setup_session(user) - conn = conn |> post("/properties", property: @valid_data) - - conn = get conn, "/" - assert html_response(conn, 200) =~ @valid_data[:title] - assert html_response(conn, 200) =~ @valid_data[:description] - assert html_response(conn, 200) =~ @valid_data[:location] - assert html_response(conn, 200) =~ Float.to_string(@valid_data[:price]) - assert html_response(conn, 200) =~ Integer.to_string(@valid_data[:room_count]) - assert html_response(conn, 200) =~ Float.to_string(@valid_data[:area]) + property_data = Map.put(@valid_data, "photos", [test_photo]) + conn = conn |> post("/properties", property: property_data) + + property_path = redirected_to(conn) + conn = get(conn, property_path) + assert html_response(conn, 200) =~ "Property created successfully!" + + conn = build_conn() + conn = get(conn, "/") + + response = html_response(conn, 200) + assert response =~ @valid_data[:title] + assert response =~ @valid_data[:description] + assert response =~ @valid_data[:location] + assert response =~ Float.to_string(@valid_data[:price]) + assert response =~ Integer.to_string(@valid_data[:room_count]) + assert response =~ Float.to_string(@valid_data[:area]) + + property = Repo.one(Property) |> Repo.preload(:photos) + photo = List.first(property.photos) + assert response =~ photo.filename end - #UPDATE TESTS + + # #UPDATE TESTS test "owner can update their property with valid data", %{conn: conn, user: user} do property = %Property{ reference: Ecto.UUID.generate(),