diff --git a/features/config.exs b/features/config.exs index f9fc77577c6c07344671ab4cd110a713dac3906b..904424dd3c62fe5f1098933109e3abf2ca474f1a 100644 --- a/features/config.exs +++ b/features/config.exs @@ -21,6 +21,10 @@ defmodule WhiteBreadConfig do context: UserProfileContext, feature_paths: ["features/user_profile.feature"] + suite name: "FR-09 Property Update Features", + context: PropertyUpdateContext, + feature_paths: ["features/property_update.feature"] + suite name: "FR-08 Property Advertisement Creation Features", context: AdvertisementCreationContext, feature_paths: ["features/advertisement_creation.feature"] diff --git a/features/contexts/property_update_context.exs b/features/contexts/property_update_context.exs new file mode 100644 index 0000000000000000000000000000000000000000..5e916f85b363bf214728b7dd015b78087586f0c4 --- /dev/null +++ b/features/contexts/property_update_context.exs @@ -0,0 +1,159 @@ +defmodule PropertyUpdateContext do + use WhiteBread.Context + use Hound.Helpers + alias PropTrackr.Repo + alias PropTrackr.Accounts.User + alias PropTrackr.Properties.Property + + scenario_starting_state fn _state -> + Ecto.Adapters.SQL.Sandbox.checkout(PropTrackr.Repo) + Ecto.Adapters.SQL.Sandbox.mode(PropTrackr.Repo, {:shared, self()}) + Hound.start_session() + %{} + end + + scenario_finalize fn _status, _state -> + Ecto.Adapters.SQL.Sandbox.checkin(PropTrackr.Repo) + Hound.end_session() + end + + given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} -> + 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) + random_user = List.last(table) + + { + :ok, + state + |> Map.put(:email, existing_user[:email]) + |> Map.put(:password, existing_user[:password]) + |> Map.put(:random_email, random_user[:email]) + |> Map.put(:random_password, random_user[:password]) + } + end + + and_ ~r/^the following properties exist$/, fn state, %{ table_data: table } -> + logged_in_user = Repo.get_by(User, email: state[:email]) + + advertisements = + table + |> Enum.map(fn details -> details |> Map.put(:reference, Ecto.UUID.generate()) end) + |> Enum.map(fn details -> {Ecto.build_assoc(logged_in_user, :properties, details), details} end) + |> Enum.map(fn {assoc, details} -> Property.changeset(assoc, details) end) + |> Enum.map(fn changeset -> Repo.insert!(changeset) end) + + { + :ok, + state + |> Map.put(:advertisements, advertisements) + } + end + + and_ ~r/^I am logged in$/, fn state -> + setup_session(state[:email], state[:password]) + {:ok, state} + end + + and_ ~r/^I navigate to my property's details page$/, fn state -> + advertisement = List.first(state[:advertisements]) + click({:id, "edit-#{advertisement.reference}"}) + {:ok, state} + end + + when_ ~r/^I click the edit button$/, fn state -> + click({:id, "edit-property"}) + {:ok, state} + end + + and_ ~r/^I update the following fields$/, fn state, %{ table_data: table } -> + data = List.first(table) + fill_field({:id, "title"}, data[:title]) + fill_field({:id, "description"}, data[:description]) + select_dropdown("type", data[:type]) + select_dropdown("property_type", data[:property_type]) + select_dropdown("state", data[:state]) + fill_field({:id, "location"}, data[:location]) + fill_field({:id, "room_count"}, data[:room_count]) + fill_field({:id, "area"}, data[:area]) + fill_field({:id, "floor"}, data[:floor]) + fill_field({:id, "price"}, data[:price]) + { + :ok, + state + |> Map.put(:updated_data, data) + } + end + + and_ ~r/^I click save changes$/, fn state -> + click({:id, "save-changes"}) + {:ok, state} + end + + then_ ~r/^I should see a success message$/, fn state -> + assert visible_in_page? ~r/Property updated successfully./ + {:ok, state} + end + + and_ ~r/^I should be redirected back to the property page$/, fn state -> + advertisement = List.first(state[:advertisements]) + assert current_path() == "/properties/#{advertisement.reference}" + {:ok, state} + end + + and_ ~r/^I should see the updated property details$/, fn state -> + advertisement = state[:updated_data] + assert visible_in_page? ~r/#{advertisement.title}/ + assert visible_in_page? ~r/#{advertisement.description}/ + assert visible_in_page? ~r/#{advertisement.type}/i + assert visible_in_page? ~r/#{advertisement.property_type}/i + assert visible_in_page? ~r/#{advertisement.location}/ + assert visible_in_page? ~r/#{advertisement.price}/ + assert visible_in_page? ~r/#{advertisement.room_count}/ + assert visible_in_page? ~r/#{advertisement.area}/ + assert visible_in_page? ~r/#{advertisement.floor}/ + assert visible_in_page? ~r/#{advertisement.state}/i + + {:ok, state} + end + + then_ ~r/^I should see error message$/, fn state -> + assert visible_in_page? ~r/Oops, something went wrong! Please check the errors below./ + {:ok, state} + end + + and_ ~r/^I should see the edit page again$/, fn state -> + assert visible_in_page? ~r/Edit Property/ + {:ok, state} + end + + and_ ~r/^I am logged in as a random user who does not own the advertisement$/, fn state -> + setup_session(state[:random_email], state[:random_password]) + {:ok, state} + end + + and_ ~r/^I navigate to the property's details page$/, fn state -> + advertisement = List.first(state[:advertisements]) + click({:id, "edit-#{advertisement.reference}"}) + {:ok, state} + end + + then_ ~r/^I should not see an edit button$/, fn state -> + assert not visible_in_page? ~r/Edit Property/ + {:ok, state} + end + + defp setup_session(email, password) do + navigate_to("/login") + fill_field({:id, "email"}, email) + fill_field({:id, "password"}, password) + click({:id, "login_button"}) + end + + # https://stackoverflow.com/a/49861811 + defp select_dropdown(drop_down_id, option) do + find_element(:css, "##{drop_down_id} option[value='#{option}']") |> click() + end +end diff --git a/features/property_update.feature b/features/property_update.feature new file mode 100644 index 0000000000000000000000000000000000000000..abbcfc6af5e89b3969b4101aa9dc5c4d550ff8a0 --- /dev/null +++ b/features/property_update.feature @@ -0,0 +1,48 @@ +Feature: Property Update + + Scenario: Owner can update their property + 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 | + And the following properties exist + | title | description | type | property_type | state | location | room_count | area | floor | floor_count | price + | Really cool property | Selling this really really house | sell | house | available | London | 3 | 100.0 | 2 | 5 | 500000 + And I am logged in + And I navigate to my property's details page + When I click the edit button + And I update the following fields + | title | description | type | property_type | state | location | room_count | area | floor | price | + | Really AMAZING property | Pls rent this really amazing apartment | rent | apartment | reserved | Paris | 4 | 120.0 | 3 | 600 | + And I click save changes + Then I should see a success message + And I should be redirected back to the property page + And I should see the updated property details + + Scenario: Owner cannot update their property when entering invalid details + 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 | + And the following properties exist + | title | description | type | property_type | location | room_count | area | floor | floor_count | price + | Really cool property | Selling this really really house | sell | house | London | 3 | 100.0 | 2 | 5 | 500000 + And I am logged in + And I navigate to my property's details page + When I click the edit button + And I update the following fields + | title | description | type | property_type | state | location | room_count | area | floor | price | + | Too short | Too short | rent | house | available | Paris | 4 | -120.0 | 3 | -600 | + And I click save changes + Then I should see error message + And I should see the edit page again + + Scenario: A random user cannot update a property + 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 | + | Random | Account | 2000-01-01 | 000 | random.account@gmail.com | password | password | + And the following properties exist + | title | description | type | property_type | state | location | room_count | area | floor | floor_count | price + | Really cool property | Selling this really really house | sell | house | available | London | 3 | 100.0 | 2 | 5 | 500000 + And I am logged in as a random user who does not own the advertisement + And I navigate to the property's details page + Then I should not see an edit button diff --git a/lib/proptrackr_web/controllers/properties_controller.ex b/lib/proptrackr_web/controllers/properties_controller.ex index 8d85a9537b3d2b7b5848cd7ec1ebe349439f9ec3..92a9e415fe76e1feea06203059579f969f1d8ce9 100644 --- a/lib/proptrackr_web/controllers/properties_controller.ex +++ b/lib/proptrackr_web/controllers/properties_controller.ex @@ -14,6 +14,28 @@ defmodule PropTrackrWeb.PropertiesController do render conn, "index.html", properties: properties end + def show(conn, %{"reference" => reference}) do + property = Repo.one( + from p in Property, + where: p.reference == ^reference, + preload: [:user], + select: p + ) + + current_user = conn.assigns.current_user + can_edit = current_user && current_user.id == property.user_id + + case property do + nil -> + conn + |> put_flash(:error, "Property not found.") + |> redirect(to: ~p"/") + property -> + render(conn, "show.html", property: property, can_edit: can_edit) + end + end + + def new(conn, _params) do changeset = Property.changeset(%Property{}, %{}) render conn, "new.html", changeset: changeset @@ -46,8 +68,52 @@ defmodule PropTrackrWeb.PropertiesController do end end - def show(conn, %{"id" => id}) do - # TODO: Fill this in with the appropriate code (FR-12) - render conn, "index.html", properties: [] + def edit(conn, %{"reference" => reference}) do + property = Repo.get_by(Property, reference: reference) + + case property do + nil -> + conn + |> put_flash(:error, "Property not found.") + |> redirect(to: ~p"/") + property -> + if conn.assigns.current_user && conn.assigns.current_user.id == property.user_id do + changeset = Property.changeset(property) + render(conn, "edit.html", property: property, changeset: changeset) + else + conn + |> put_flash(:error, "You don't have permission to edit this property.") + |> redirect(to: ~p"/properties/#{property.reference}") + end + end end + + def update(conn, %{"reference" => reference, "property" => property_params}) do + property = Repo.get_by(Property, reference: reference) + + case property do + nil -> + conn + |> put_flash(:error, "Property not found.") + |> redirect(to: ~p"/") + + property -> + if conn.assigns.current_user && conn.assigns.current_user.id == property.user_id do + case Repo.update(Property.changeset(property, property_params)) do + {:ok, updated_property} -> + conn + |> put_flash(:info, "Property updated successfully.") + |> redirect(to: ~p"/properties/#{updated_property.reference}") + + {:error, changeset} -> + render(conn, "edit.html", property: property, changeset: changeset) + end + else + conn + |> put_flash(:error, "You don't have permission to update this property.") + |> redirect(to: ~p"/properties/#{property.reference}") + end + end + end + end diff --git a/lib/proptrackr_web/controllers/properties_html/edit.html.heex b/lib/proptrackr_web/controllers/properties_html/edit.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..1970b8902b89347890617124082387669bc5ba68 --- /dev/null +++ b/lib/proptrackr_web/controllers/properties_html/edit.html.heex @@ -0,0 +1,80 @@ +<.header> + Edit Property + <:subtitle>Update property details</:subtitle> +</.header> + +<.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. +</.error> + +<.simple_form + :let={f} + for={@changeset} + method="put" + action={~p"/properties/#{@property.reference}"} +> + <.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="Type" + options={[:rent, :sell]} + required + /> + <.input + field={f[:property_type]} + id="property_type" + type="select" + label="Property Type" + options={[:apartment, :house, :other]} + required + /> + <.input + field={f[:state]} + id="state" + type="select" + label="State" + options={[:available, :reserved, :unavailable]} + required + /> + <.input field={f[:location]} id="location" type="text" label="Location" required /> + <.input + field={f[:room_count]} + id="room_count" + type="number" + label="Room Count" + required + /> + <.input + field={f[:area]} + id="area" + type="number" + step="0.01" + label="Area (mآ²)" + required + /> + <.input field={f[:floor]} id="floor" type="number" label="Floor" required /> + <.input + field={f[:floor_count]} + id="floor_count" + type="number" + label="Total Floors" + required + /> + <.input field={f[:price]} id="price" type="number" step="0.01" label="Price" required /> + + <:actions> + <.button id="save-changes">Save Changes</.button> + <.link href={~p"/properties/#{@property.reference}"} class="ml-4"> + Cancel + </.link> + </:actions> +</.simple_form> diff --git a/lib/proptrackr_web/controllers/properties_html/index.html.heex b/lib/proptrackr_web/controllers/properties_html/index.html.heex index 87e1b99ffb84bed24c1491db9c09ae16a004bede..4b7bdb72c0d21a9de917b2c5c626e00681e8b075 100644 --- a/lib/proptrackr_web/controllers/properties_html/index.html.heex +++ b/lib/proptrackr_web/controllers/properties_html/index.html.heex @@ -29,9 +29,8 @@ </div> <div class="flex flex-row justify-end"> - <!-- TODO: Implement linking --> <.link href={~p"/properties/#{property.reference}"}> - <.button type="button" class="text-white rounded px-4 py-2">View more</.button> + <.button type="button" class="text-white rounded px-4 py-2" id={ "edit-#{property.reference}" }>View more</.button> </.link> </div> </div> diff --git a/lib/proptrackr_web/controllers/properties_html/show.html.heex b/lib/proptrackr_web/controllers/properties_html/show.html.heex new file mode 100644 index 0000000000000000000000000000000000000000..c045baddfada33d5bc0408db9dc308ae3cb08bd6 --- /dev/null +++ b/lib/proptrackr_web/controllers/properties_html/show.html.heex @@ -0,0 +1,84 @@ +<.flash_group flash={@flash} /> + +<.header> + <%= @property.title %> + <:actions> + <%= if @can_edit do %> + <.link + href={~p"/properties/#{@property.reference}/edit"} + class="bg-zinc-900 hover:bg-zinc-700 text-white px-4 py-2 rounded-md mr-4" + id="edit-property" + > + Edit Property + </.link> + <% end %> + <.link href={~p"/"}> + Back to listings + </.link> + </:actions> +</.header> + +<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"> + <div> + <dt class="text-sm font-medium text-gray-500">Type</dt> + <dd class="mt-1 text-sm text-gray-900"> + <%= String.capitalize(to_string(@property.type)) %> + </dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Property Type</dt> + <dd class="mt-1 text-sm text-gray-900"> + <%= String.capitalize(to_string(@property.property_type)) %> + </dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Status</dt> + <dd class="mt-1 text-sm text-gray-900"> + <%= String.capitalize(to_string(@property.state)) %> + </dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Location</dt> + <dd class="mt-1 text-sm text-gray-900"><%= @property.location %></dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Price</dt> + <dd class="mt-1 text-sm text-gray-900"> + €<%= :erlang.float_to_binary(@property.price, decimals: 2) %> + </dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Area</dt> + <dd class="mt-1 text-sm text-gray-900"><%= @property.area %> mآ²</dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Rooms</dt> + <dd class="mt-1 text-sm text-gray-900"><%= @property.room_count %></dd> + </div> + <div> + <dt class="text-sm font-medium text-gray-500">Floor</dt> + <dd class="mt-1 text-sm text-gray-900"> + <%= @property.floor %>/<%= @property.floor_count %> + </dd> + </div> + </dl> + </div> + + <div class="bg-white shadow rounded-lg p-6"> + <h3 class="text-lg font-medium text-gray-900">Description</h3> + <p class="mt-4 text-sm text-gray-600"><%= @property.description %></p> + </div> + + <div class="bg-white shadow rounded-lg p-6"> + <h3 class="text-lg font-medium text-gray-900">Contact Information</h3> + <div class="mt-4"> + <p class="text-sm text-gray-600"> + Listed by <%= @property.user.name %> <%= @property.user.surname %> + </p> + <p class="text-sm text-gray-600">Phone: <%= @property.user.phone_number %></p> + <p class="text-sm text-gray-600">Email: <%= @property.user.email %></p> + </div> + </div> +</div> diff --git a/lib/proptrackr_web/router.ex b/lib/proptrackr_web/router.ex index 8ee37317db2db1949cb18f9c7c390b56f43cb9d6..059a73406dd60c6502a875fd0101a3240806d82e 100644 --- a/lib/proptrackr_web/router.ex +++ b/lib/proptrackr_web/router.ex @@ -34,7 +34,9 @@ defmodule PropTrackrWeb.Router do get "/", PropertiesController, :index get "/properties/new", PropertiesController, :new post "/properties", PropertiesController, :create - get "/properties/:id", PropertiesController, :show + get "/properties/:reference", PropertiesController, :show + get "/properties/:reference/edit", PropertiesController, :edit + put "/properties/:reference", PropertiesController, :update end # Other scopes may use custom stacks. diff --git a/test/proptrackr_web/controllers/properties_controller_test.exs b/test/proptrackr_web/controllers/properties_controller_test.exs index ba200f7c2b1802650f63825e17b99a655ffb59c8..2f1e46eb76cf5d08fb456aea12759080e6ec6ebb 100644 --- a/test/proptrackr_web/controllers/properties_controller_test.exs +++ b/test/proptrackr_web/controllers/properties_controller_test.exs @@ -6,7 +6,7 @@ defmodule PropTrackrWeb.PropertiesControllerTest do @valid_data %{ title: "Apartment", - description: "Small apartment", + description: "Small apartment with a beautiful view of the city center", type: "rent", property_type: "apartment", location: "Tartu, Estonia", @@ -30,6 +30,19 @@ defmodule PropTrackrWeb.PropertiesControllerTest do price: -1.0, } + @update_data %{ + title: "Updated Apartment", + description: "Newly renovated small apartment in the heart of the city with modern amenities", + type: "sell", + property_type: "apartment", + location: "Tallinn, Estonia", + room_count: 2, + area: 15.0, + floor: 3, + floor_count: 5, + price: 500.0, + } + setup do user = %User{ name: "Test", @@ -43,7 +56,20 @@ defmodule PropTrackrWeb.PropertiesControllerTest do } user = Repo.insert!(user) - {:ok, %{user: user}} + other_user = %User{ + name: "Other", + surname: "User", + birth_date: "2000-01-01", + phone_number: "111", + bio: "Hey", + email: "other.user@gmail.com", + password: "testing", + confirm_password: "testing", + } + other_user = Repo.insert!(other_user) + + {:ok, %{user: user, other_user: other_user}} + end test "Authenticated user should be able to create a new property advertisement with valid data", %{ conn: conn, user: user } do @@ -123,6 +149,83 @@ defmodule PropTrackrWeb.PropertiesControllerTest do assert html_response(conn, 200) =~ Float.to_string(@valid_data[:area]) end + #Update tests + test "owner can update their property with valid data", %{conn: conn, user: user} do + property = %Property{ + reference: Ecto.UUID.generate(), + user_id: user.id + } + property = property + |> Property.changeset(@valid_data) + |> Repo.insert!() + + conn = conn |> setup_session(user) + conn = put(conn, ~p"/properties/#{property.reference}", property: @update_data) + + assert redirected_to(conn) == ~p"/properties/#{property.reference}" + + conn = get(conn, ~p"/properties/#{property.reference}") + assert html_response(conn, 200) =~ "Property updated successfully" + + updated_property = Repo.get_by!(Property, reference: property.reference) + assert updated_property.title == @update_data[:title] + assert updated_property.description == @update_data[:description] + assert updated_property.type == String.to_atom(@update_data[:type]) + assert updated_property.property_type == String.to_atom(@update_data[:property_type]) + assert updated_property.location == @update_data[:location] + assert updated_property.room_count == @update_data[:room_count] + assert updated_property.area == @update_data[:area] + assert updated_property.floor == @update_data[:floor] + assert updated_property.floor_count == @update_data[:floor_count] + assert updated_property.price == @update_data[:price] + end + + test "owner cannot update property with invalid data", %{conn: conn, user: user} do + property = %Property{ + reference: Ecto.UUID.generate(), + user_id: user.id + } + property = property + |> Property.changeset(@valid_data) + |> Repo.insert!() + + conn = conn |> setup_session(user) + conn = put(conn, ~p"/properties/#{property.reference}", property: @invalid_data) + + assert html_response(conn, 200) =~ "must be greater than 0" + + unchanged_property = Repo.get_by!(Property, reference: property.reference) + assert unchanged_property.title == @valid_data[:title] + end + + test "non-owner cannot update property", %{conn: conn, user: user, other_user: other_user} do + property = %Property{ + reference: Ecto.UUID.generate(), + user_id: user.id + } + property = property + |> Property.changeset(@valid_data) + |> Repo.insert!() + + conn = conn |> setup_session(other_user) + conn = put(conn, ~p"/properties/#{property.reference}", property: @update_data) + + assert redirected_to(conn) == ~p"/properties/#{property.reference}" + assert get_flash(conn, :error) == "You don't have permission to update this property." + + unchanged_property = Repo.get_by!(Property, reference: property.reference) + assert unchanged_property.title == @valid_data[:title] + end + + test "cannot update non-existent property", %{conn: conn, user: user} do + conn = conn |> setup_session(user) + conn = put(conn, ~p"/properties/non-existent-reference", property: @update_data) + + assert redirected_to(conn) == "/" + assert get_flash(conn, :error) == "Property not found." + end + + defp setup_session(conn, user) do conn = conn |> post("/login", email: user.email, password: user.password) conn = get conn, redirected_to(conn)