From e8fd635ddb2772ca7798140bf87ea3307bdad315 Mon Sep 17 00:00:00 2001 From: Kevin Sivic Date: Sun, 30 Nov 2025 23:55:29 -0500 Subject: [PATCH] Add Businesses context with A-Frame architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - Database foundation: - Categories, businesses, hours, photos migrations - Ecto schemas with changesets and validations Phase 2 - Business logic: - Logic module with pure functions (Haversine distance, slug generation, etc.) - Queries module for database operations with filtering - Businesses context coordinating Logic and Queries layers - 43 tests covering logic and queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 11 + lib/localspot/businesses.ex | 155 +++++++++++ lib/localspot/businesses/business.ex | 60 +++++ lib/localspot/businesses/business_hour.ex | 46 ++++ lib/localspot/businesses/business_photo.ex | 25 ++ lib/localspot/businesses/category.ex | 25 ++ lib/localspot/businesses/logic.ex | 132 +++++++++ lib/localspot/businesses/queries.ex | 126 +++++++++ .../20251201042317_create_categories.exs | 16 ++ .../20251201042510_create_businesses.exs | 40 +++ .../20251201042602_create_business_hours.exs | 19 ++ .../20251201042635_create_business_photos.exs | 18 ++ test/localspot/businesses/.logic_test.exs.swp | Bin 0 -> 16384 bytes test/localspot/businesses/logic_test.exs | 208 +++++++++++++++ test/localspot/businesses/queries_test.exs | 252 ++++++++++++++++++ 15 files changed, 1133 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 lib/localspot/businesses.ex create mode 100644 lib/localspot/businesses/business.ex create mode 100644 lib/localspot/businesses/business_hour.ex create mode 100644 lib/localspot/businesses/business_photo.ex create mode 100644 lib/localspot/businesses/category.ex create mode 100644 lib/localspot/businesses/logic.ex create mode 100644 lib/localspot/businesses/queries.ex create mode 100644 priv/repo/migrations/20251201042317_create_categories.exs create mode 100644 priv/repo/migrations/20251201042510_create_businesses.exs create mode 100644 priv/repo/migrations/20251201042602_create_business_hours.exs create mode 100644 priv/repo/migrations/20251201042635_create_business_photos.exs create mode 100644 test/localspot/businesses/.logic_test.exs.swp create mode 100644 test/localspot/businesses/logic_test.exs create mode 100644 test/localspot/businesses/queries_test.exs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..99b7f6b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(mix compile:*)", + "Bash(mix test:*)", + "Bash(mix format:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/lib/localspot/businesses.ex b/lib/localspot/businesses.ex new file mode 100644 index 0000000..36bd079 --- /dev/null +++ b/lib/localspot/businesses.ex @@ -0,0 +1,155 @@ +defmodule Localspot.Businesses do + @moduledoc """ + Application layer - the Businesses context. + Coordinates Logic and Infrastructure layers. + """ + + alias Localspot.Businesses.{Logic, Queries} + alias Localspot.Businesses.{Business, BusinessHour, BusinessPhoto} + alias Localspot.Repo + + # --- Categories --- + + @doc """ + List all categories. + """ + defdelegate list_categories, to: Queries + + @doc """ + Get a category by ID. + """ + defdelegate get_category(id), to: Queries + + @doc """ + Get a category by slug. + """ + defdelegate get_category_by_slug(slug), to: Queries + + @doc """ + Create a new category. + """ + defdelegate create_category(attrs), to: Queries + + # --- Businesses --- + + @doc """ + List businesses with optional filters. + Filters can include: query, category_id, latitude, longitude, radius_miles, locally_owned_only + """ + def list_businesses(params \\ %{}) do + filters = Logic.build_search_filters(params) + Queries.list_businesses(filters) + end + + @doc """ + Get a business by ID. + """ + defdelegate get_business(id), to: Queries + + @doc """ + Get a business by slug. + """ + defdelegate get_business_by_slug(slug), to: Queries + + @doc """ + Create a new business. + """ + defdelegate create_business(attrs), to: Queries + + @doc """ + Get business counts per category. + """ + defdelegate count_businesses_by_category, to: Queries + + # --- Business with associations --- + + @doc """ + Create a business with hours and photos in a single transaction. + """ + def create_business_with_associations(attrs, hours_attrs \\ [], photos_attrs \\ []) do + Repo.transaction(fn -> + with {:ok, business} <- create_business(attrs), + :ok <- create_hours(business, hours_attrs), + :ok <- create_photos(business, photos_attrs) do + # Reload with associations + Queries.get_business(business.id) + else + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + defp create_hours(_business, []), do: :ok + + defp create_hours(business, hours_attrs) do + results = + Enum.map(hours_attrs, fn attrs -> + %BusinessHour{} + |> BusinessHour.changeset(Map.put(attrs, :business_id, business.id)) + |> Repo.insert() + end) + + if Enum.all?(results, &match?({:ok, _}, &1)) do + :ok + else + error = Enum.find(results, &match?({:error, _}, &1)) + error + end + end + + defp create_photos(_business, []), do: :ok + + defp create_photos(business, photos_attrs) do + results = + Enum.map(photos_attrs, fn attrs -> + %BusinessPhoto{} + |> BusinessPhoto.changeset(Map.put(attrs, :business_id, business.id)) + |> Repo.insert() + end) + + if Enum.all?(results, &match?({:ok, _}, &1)) do + :ok + else + error = Enum.find(results, &match?({:error, _}, &1)) + error + end + end + + # --- Logic helpers --- + + @doc """ + Check if a business is currently open. + """ + defdelegate currently_open?(hours, now \\ DateTime.utc_now()), to: Logic + + @doc """ + Format a phone number for display. + """ + defdelegate format_phone(phone), to: Logic + + @doc """ + Get the name of a day from its integer representation. + """ + defdelegate day_name(day_of_week), to: Logic + + @doc """ + Generate a URL-friendly slug from a name. + """ + defdelegate generate_slug(name), to: Logic + + # --- Changesets for forms --- + + @doc """ + Returns a changeset for tracking business changes. + """ + def change_business(%Business{} = business, attrs \\ %{}) do + Business.changeset(business, attrs) + end + + @doc """ + Returns a new business struct for forms. + """ + def new_business do + %Business{} + end +end diff --git a/lib/localspot/businesses/business.ex b/lib/localspot/businesses/business.ex new file mode 100644 index 0000000..09f3b42 --- /dev/null +++ b/lib/localspot/businesses/business.ex @@ -0,0 +1,60 @@ +defmodule Localspot.Businesses.Business do + @moduledoc """ + Schema for businesses in the directory. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "businesses" do + field :name, :string + field :slug, :string + field :description, :string + field :phone, :string + field :email, :string + field :website, :string + field :street_address, :string + field :city, :string + field :state, :string + field :zip_code, :string + field :latitude, :decimal + field :longitude, :decimal + field :locally_owned, :boolean, default: true + field :active, :boolean, default: true + + # Virtual field for distance calculations + field :distance_miles, :float, virtual: true + + belongs_to :category, Localspot.Businesses.Category + has_many :hours, Localspot.Businesses.BusinessHour + has_many :photos, Localspot.Businesses.BusinessPhoto + + timestamps(type: :utc_datetime) + end + + def changeset(business, attrs) do + business + |> cast(attrs, [ + :name, + :slug, + :description, + :phone, + :email, + :website, + :street_address, + :city, + :state, + :zip_code, + :latitude, + :longitude, + :locally_owned, + :active, + :category_id + ]) + |> validate_required([:name, :slug, :street_address, :city, :state, :zip_code, :category_id]) + |> validate_format(:email, ~r/@/, message: "must be a valid email") + |> validate_number(:latitude, greater_than_or_equal_to: -90, less_than_or_equal_to: 90) + |> validate_number(:longitude, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) + |> unique_constraint(:slug) + |> foreign_key_constraint(:category_id) + end +end diff --git a/lib/localspot/businesses/business_hour.ex b/lib/localspot/businesses/business_hour.ex new file mode 100644 index 0000000..b88cab5 --- /dev/null +++ b/lib/localspot/businesses/business_hour.ex @@ -0,0 +1,46 @@ +defmodule Localspot.Businesses.BusinessHour do + @moduledoc """ + Schema for business operating hours. + """ + use Ecto.Schema + import Ecto.Changeset + + @days_of_week 0..6 + + schema "business_hours" do + field :day_of_week, :integer + field :opens_at, :time + field :closes_at, :time + field :closed, :boolean, default: false + + belongs_to :business, Localspot.Businesses.Business + + timestamps(type: :utc_datetime) + end + + def changeset(hour, attrs) do + hour + |> cast(attrs, [:day_of_week, :opens_at, :closes_at, :closed, :business_id]) + |> validate_required([:day_of_week, :business_id]) + |> validate_inclusion(:day_of_week, @days_of_week) + |> validate_hours_logic() + |> unique_constraint([:business_id, :day_of_week]) + end + + defp validate_hours_logic(changeset) do + closed = get_field(changeset, :closed) + opens_at = get_field(changeset, :opens_at) + closes_at = get_field(changeset, :closes_at) + + cond do + closed -> + changeset + + is_nil(opens_at) or is_nil(closes_at) -> + add_error(changeset, :opens_at, "opening and closing times required when not closed") + + true -> + changeset + end + end +end diff --git a/lib/localspot/businesses/business_photo.ex b/lib/localspot/businesses/business_photo.ex new file mode 100644 index 0000000..1b0cd15 --- /dev/null +++ b/lib/localspot/businesses/business_photo.ex @@ -0,0 +1,25 @@ +defmodule Localspot.Businesses.BusinessPhoto do + @moduledoc """ + Schema for business photos (URL references). + """ + use Ecto.Schema + import Ecto.Changeset + + schema "business_photos" do + field :url, :string + field :alt_text, :string + field :position, :integer, default: 0 + field :primary, :boolean, default: false + + belongs_to :business, Localspot.Businesses.Business + + timestamps(type: :utc_datetime) + end + + def changeset(photo, attrs) do + photo + |> cast(attrs, [:url, :alt_text, :position, :primary, :business_id]) + |> validate_required([:url, :business_id]) + |> validate_format(:url, ~r/^https?:\/\//, message: "must be a valid URL") + end +end diff --git a/lib/localspot/businesses/category.ex b/lib/localspot/businesses/category.ex new file mode 100644 index 0000000..f4998b0 --- /dev/null +++ b/lib/localspot/businesses/category.ex @@ -0,0 +1,25 @@ +defmodule Localspot.Businesses.Category do + @moduledoc """ + Schema for business categories. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "categories" do + field :name, :string + field :slug, :string + field :description, :string + field :icon, :string + + has_many :businesses, Localspot.Businesses.Business + + timestamps(type: :utc_datetime) + end + + def changeset(category, attrs) do + category + |> cast(attrs, [:name, :slug, :description, :icon]) + |> validate_required([:name, :slug]) + |> unique_constraint(:slug) + end +end diff --git a/lib/localspot/businesses/logic.ex b/lib/localspot/businesses/logic.ex new file mode 100644 index 0000000..1c7da50 --- /dev/null +++ b/lib/localspot/businesses/logic.ex @@ -0,0 +1,132 @@ +defmodule Localspot.Businesses.Logic do + @moduledoc """ + Pure business logic for the Businesses context. + No external dependencies - all functions are pure. + """ + + @earth_radius_miles 3958.8 + + @doc """ + Calculate distance between two points using Haversine formula. + Returns distance in miles. + """ + def haversine_distance(lat1, lng1, lat2, lng2) do + lat1_rad = degrees_to_radians(lat1) + lat2_rad = degrees_to_radians(lat2) + delta_lat = degrees_to_radians(lat2 - lat1) + delta_lng = degrees_to_radians(lng2 - lng1) + + a = + :math.sin(delta_lat / 2) * :math.sin(delta_lat / 2) + + :math.cos(lat1_rad) * :math.cos(lat2_rad) * + :math.sin(delta_lng / 2) * :math.sin(delta_lng / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + + @earth_radius_miles * c + end + + defp degrees_to_radians(degrees), do: degrees * :math.pi() / 180 + + @doc """ + Generate a URL-friendly slug from a business name. + """ + def generate_slug(name) when is_binary(name) do + name + |> String.downcase() + |> String.replace(~r/[^a-z0-9\s-]/, "") + |> String.replace(~r/\s+/, "-") + |> String.trim("-") + end + + @doc """ + Format phone number for display. + """ + def format_phone(nil), do: nil + + def format_phone(phone) do + digits = String.replace(phone, ~r/\D/, "") + + case String.length(digits) do + 10 -> + "#{String.slice(digits, 0, 3)}-#{String.slice(digits, 3, 3)}-#{String.slice(digits, 6, 4)}" + + 11 -> + "+1 #{String.slice(digits, 1, 3)}-#{String.slice(digits, 4, 3)}-#{String.slice(digits, 7, 4)}" + + _ -> + phone + end + end + + @doc """ + Get the day name from day_of_week integer. + 0 = Sunday, 6 = Saturday + """ + def day_name(0), do: "Sunday" + def day_name(1), do: "Monday" + def day_name(2), do: "Tuesday" + def day_name(3), do: "Wednesday" + def day_name(4), do: "Thursday" + def day_name(5), do: "Friday" + def day_name(6), do: "Saturday" + + @doc """ + Check if a business is currently open based on hours. + """ + def currently_open?(hours, now \\ DateTime.utc_now()) do + # Convert to 0-indexed day of week (0 = Sunday, 6 = Saturday) + # Date.day_of_week returns 1=Monday..7=Sunday, we need 0=Sunday..6=Saturday + elixir_day = Date.day_of_week(DateTime.to_date(now)) + day_of_week = rem(elixir_day, 7) + current_time = DateTime.to_time(now) + + hours + |> Enum.find(fn h -> h.day_of_week == day_of_week end) + |> case do + nil -> + false + + %{closed: true} -> + false + + %{opens_at: opens, closes_at: closes} -> + Time.compare(current_time, opens) in [:gt, :eq] and + Time.compare(current_time, closes) == :lt + end + end + + @doc """ + Build search filter criteria from params. + """ + def build_search_filters(params) do + %{ + query: Map.get(params, "query", "") |> String.trim(), + category_id: parse_integer(Map.get(params, "category_id")), + latitude: parse_float(Map.get(params, "latitude")), + longitude: parse_float(Map.get(params, "longitude")), + radius_miles: parse_float(Map.get(params, "radius", "10")), + locally_owned_only: Map.get(params, "locally_owned") == "true" + } + end + + defp parse_integer(nil), do: nil + defp parse_integer(""), do: nil + + defp parse_integer(val) when is_binary(val) do + case Integer.parse(val) do + {int, _} -> int + :error -> nil + end + end + + defp parse_float(nil), do: nil + defp parse_float(""), do: nil + + defp parse_float(val) when is_binary(val) do + case Float.parse(val) do + {float, _} -> float + :error -> nil + end + end +end diff --git a/lib/localspot/businesses/queries.ex b/lib/localspot/businesses/queries.ex new file mode 100644 index 0000000..fa9414b --- /dev/null +++ b/lib/localspot/businesses/queries.ex @@ -0,0 +1,126 @@ +defmodule Localspot.Businesses.Queries do + @moduledoc """ + Infrastructure layer - database queries for the Businesses context. + Wraps Ecto.Repo operations with clean interfaces. + """ + + import Ecto.Query + alias Localspot.Repo + alias Localspot.Businesses.{Business, Category} + alias Localspot.Businesses.Logic + + # --- Categories --- + + def list_categories do + Category + |> order_by(:name) + |> Repo.all() + end + + def get_category(id), do: Repo.get(Category, id) + + def get_category_by_slug(slug), do: Repo.get_by(Category, slug: slug) + + def create_category(attrs) do + %Category{} + |> Category.changeset(attrs) + |> Repo.insert() + end + + # --- Businesses --- + + def list_businesses(filters \\ %{}) do + Business + |> where([b], b.active == true) + |> apply_filters(filters) + |> preload([:category, :photos]) + |> order_by(:name) + |> Repo.all() + |> maybe_filter_by_distance(filters) + end + + def get_business(id) do + Business + |> where([b], b.id == ^id and b.active == true) + |> preload([:category, :hours, :photos]) + |> Repo.one() + end + + def get_business_by_slug(slug) do + Business + |> where([b], b.slug == ^slug and b.active == true) + |> preload([:category, :hours, :photos]) + |> Repo.one() + end + + def create_business(attrs) do + %Business{} + |> Business.changeset(attrs) + |> Repo.insert() + end + + def count_businesses_by_category do + Business + |> where([b], b.active == true) + |> group_by([b], b.category_id) + |> select([b], {b.category_id, count(b.id)}) + |> Repo.all() + |> Map.new() + end + + # --- Private helpers --- + + defp apply_filters(query, filters) do + query + |> filter_by_query(Map.get(filters, :query)) + |> filter_by_category(Map.get(filters, :category_id)) + |> filter_by_locally_owned(Map.get(filters, :locally_owned_only)) + end + + defp filter_by_query(query, nil), do: query + defp filter_by_query(query, ""), do: query + + defp filter_by_query(query, search_term) do + term = "%#{search_term}%" + where(query, [b], ilike(b.name, ^term) or ilike(b.description, ^term)) + end + + defp filter_by_category(query, nil), do: query + + defp filter_by_category(query, category_id) do + where(query, [b], b.category_id == ^category_id) + end + + defp filter_by_locally_owned(query, false), do: query + defp filter_by_locally_owned(query, nil), do: query + + defp filter_by_locally_owned(query, true) do + where(query, [b], b.locally_owned == true) + end + + # Post-query filtering for distance (Haversine) + defp maybe_filter_by_distance(businesses, filters) do + lat = Map.get(filters, :latitude) + lng = Map.get(filters, :longitude) + radius = Map.get(filters, :radius_miles) + + if lat && lng && radius do + businesses + |> Enum.map(fn business -> + distance = + Logic.haversine_distance( + lat, + lng, + Decimal.to_float(business.latitude), + Decimal.to_float(business.longitude) + ) + + %{business | distance_miles: Float.round(distance, 1)} + end) + |> Enum.filter(fn business -> business.distance_miles <= radius end) + |> Enum.sort_by(fn business -> business.distance_miles end) + else + businesses + end + end +end diff --git a/priv/repo/migrations/20251201042317_create_categories.exs b/priv/repo/migrations/20251201042317_create_categories.exs new file mode 100644 index 0000000..c555da6 --- /dev/null +++ b/priv/repo/migrations/20251201042317_create_categories.exs @@ -0,0 +1,16 @@ +defmodule Localspot.Repo.Migrations.CreateCategories do + use Ecto.Migration + + def change do + create table(:categories) do + add :name, :string, null: false + add :slug, :string, null: false + add :description, :text + add :icon, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:categories, [:slug]) + end +end diff --git a/priv/repo/migrations/20251201042510_create_businesses.exs b/priv/repo/migrations/20251201042510_create_businesses.exs new file mode 100644 index 0000000..145880f --- /dev/null +++ b/priv/repo/migrations/20251201042510_create_businesses.exs @@ -0,0 +1,40 @@ +defmodule Localspot.Repo.Migrations.CreateBusinesses do + use Ecto.Migration + + def change do + create table(:businesses) do + add :name, :string, null: false + add :slug, :string, null: false + add :description, :text + add :phone, :string + add :email, :string + add :website, :string + + # Address fields + add :street_address, :string, null: false + add :city, :string, null: false + add :state, :string, null: false + add :zip_code, :string, null: false + + # Location for radius search (Haversine) + add :latitude, :decimal, precision: 10, scale: 8 + add :longitude, :decimal, precision: 11, scale: 8 + + # Local ownership + add :locally_owned, :boolean, default: true, null: false + + # Status + add :active, :boolean, default: true, null: false + + add :category_id, references(:categories, on_delete: :restrict), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:businesses, [:slug]) + create index(:businesses, [:category_id]) + create index(:businesses, [:latitude, :longitude]) + create index(:businesses, [:city, :state]) + create index(:businesses, [:active]) + end +end diff --git a/priv/repo/migrations/20251201042602_create_business_hours.exs b/priv/repo/migrations/20251201042602_create_business_hours.exs new file mode 100644 index 0000000..027a7b3 --- /dev/null +++ b/priv/repo/migrations/20251201042602_create_business_hours.exs @@ -0,0 +1,19 @@ +defmodule Localspot.Repo.Migrations.CreateBusinessHours do + use Ecto.Migration + + def change do + create table(:business_hours) do + add :day_of_week, :integer, null: false + add :opens_at, :time + add :closes_at, :time + add :closed, :boolean, default: false, null: false + + add :business_id, references(:businesses, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:business_hours, [:business_id]) + create unique_index(:business_hours, [:business_id, :day_of_week]) + end +end diff --git a/priv/repo/migrations/20251201042635_create_business_photos.exs b/priv/repo/migrations/20251201042635_create_business_photos.exs new file mode 100644 index 0000000..98c1040 --- /dev/null +++ b/priv/repo/migrations/20251201042635_create_business_photos.exs @@ -0,0 +1,18 @@ +defmodule Localspot.Repo.Migrations.CreateBusinessPhotos do + use Ecto.Migration + + def change do + create table(:business_photos) do + add :url, :string, null: false + add :alt_text, :string + add :position, :integer, default: 0, null: false + add :primary, :boolean, default: false, null: false + + add :business_id, references(:businesses, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:business_photos, [:business_id]) + end +end diff --git a/test/localspot/businesses/.logic_test.exs.swp b/test/localspot/businesses/.logic_test.exs.swp new file mode 100644 index 0000000000000000000000000000000000000000..39d7978e64d4e42a1ffdf7ade26a9ae6f0713a1b GIT binary patch literal 16384 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 literal 0 HcmV?d00001 diff --git a/test/localspot/businesses/logic_test.exs b/test/localspot/businesses/logic_test.exs new file mode 100644 index 0000000..aebbae0 --- /dev/null +++ b/test/localspot/businesses/logic_test.exs @@ -0,0 +1,208 @@ +defmodule Localspot.Businesses.LogicTest do + use ExUnit.Case, async: true + + alias Localspot.Businesses.Logic + + describe "haversine_distance/4" do + test "calculates distance between two points in miles" do + # New York to Los Angeles (~2,451 miles) + ny_lat = 40.7128 + ny_lng = -74.0060 + la_lat = 34.0522 + la_lng = -118.2437 + + distance = Logic.haversine_distance(ny_lat, ny_lng, la_lat, la_lng) + + assert_in_delta distance, 2451, 10 + end + + test "returns 0 for same coordinates" do + assert Logic.haversine_distance(40.0, -74.0, 40.0, -74.0) == 0.0 + end + + test "calculates short distances accurately" do + # Two points about 1 mile apart in NYC + lat1 = 40.7580 + lng1 = -73.9855 + lat2 = 40.7484 + lng2 = -73.9857 + + distance = Logic.haversine_distance(lat1, lng1, lat2, lng2) + + assert_in_delta distance, 0.66, 0.1 + end + end + + describe "generate_slug/1" do + test "converts name to URL-friendly slug" do + assert Logic.generate_slug("Joe's Coffee Shop") == "joes-coffee-shop" + end + + test "handles multiple spaces" do + assert Logic.generate_slug(" Multiple Spaces ") == "multiple-spaces" + end + + test "removes special characters" do + assert Logic.generate_slug("Special!@#$%^&*()Characters") == "specialcharacters" + end + + test "handles already lowercase names" do + assert Logic.generate_slug("already lowercase") == "already-lowercase" + end + + test "handles numbers" do + assert Logic.generate_slug("Cafe 123") == "cafe-123" + end + end + + describe "format_phone/1" do + test "formats 10-digit phone numbers" do + assert Logic.format_phone("5551234567") == "555-123-4567" + end + + test "formats phone with existing formatting" do + assert Logic.format_phone("(555) 123-4567") == "555-123-4567" + end + + test "formats 11-digit phone with country code" do + assert Logic.format_phone("15551234567") == "+1 555-123-4567" + end + + test "returns nil for nil input" do + assert Logic.format_phone(nil) == nil + end + + test "returns original for non-standard formats" do + assert Logic.format_phone("123") == "123" + end + end + + describe "day_name/1" do + test "returns correct day names" do + assert Logic.day_name(0) == "Sunday" + assert Logic.day_name(1) == "Monday" + assert Logic.day_name(2) == "Tuesday" + assert Logic.day_name(3) == "Wednesday" + assert Logic.day_name(4) == "Thursday" + assert Logic.day_name(5) == "Friday" + assert Logic.day_name(6) == "Saturday" + end + end + + describe "currently_open?/2" do + test "returns true when business is open" do + hours = [ + %{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false} + ] + + # Monday at noon UTC + monday_noon = ~U[2024-01-08 12:00:00Z] + + assert Logic.currently_open?(hours, monday_noon) == true + end + + test "returns false when business is closed for the day" do + hours = [ + %{day_of_week: 1, opens_at: nil, closes_at: nil, closed: true} + ] + + monday_noon = ~U[2024-01-08 12:00:00Z] + + assert Logic.currently_open?(hours, monday_noon) == false + end + + test "returns false when outside business hours" do + hours = [ + %{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false} + ] + + # Monday at 8pm UTC + monday_evening = ~U[2024-01-08 20:00:00Z] + + assert Logic.currently_open?(hours, monday_evening) == false + end + + test "returns false when no hours defined for day" do + hours = [ + %{day_of_week: 2, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false} + ] + + # Monday (day 1, but hours only defined for Tuesday day 2) + monday_noon = ~U[2024-01-08 12:00:00Z] + + assert Logic.currently_open?(hours, monday_noon) == false + end + + test "returns true at opening time" do + hours = [ + %{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false} + ] + + monday_9am = ~U[2024-01-08 09:00:00Z] + + assert Logic.currently_open?(hours, monday_9am) == true + end + + test "returns false at closing time" do + hours = [ + %{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false} + ] + + monday_5pm = ~U[2024-01-08 17:00:00Z] + + assert Logic.currently_open?(hours, monday_5pm) == false + end + end + + describe "build_search_filters/1" do + test "parses all search params" do + params = %{ + "query" => "coffee", + "category_id" => "5", + "latitude" => "40.7128", + "longitude" => "-74.0060", + "radius" => "25", + "locally_owned" => "true" + } + + filters = Logic.build_search_filters(params) + + assert filters.query == "coffee" + assert filters.category_id == 5 + assert filters.latitude == 40.7128 + assert filters.longitude == -74.0060 + assert filters.radius_miles == 25.0 + assert filters.locally_owned_only == true + end + + test "handles missing params with defaults" do + filters = Logic.build_search_filters(%{}) + + assert filters.query == "" + assert filters.category_id == nil + assert filters.latitude == nil + assert filters.longitude == nil + assert filters.radius_miles == 10.0 + assert filters.locally_owned_only == false + end + + test "trims query whitespace" do + filters = Logic.build_search_filters(%{"query" => " coffee "}) + + assert filters.query == "coffee" + end + + test "handles empty string params" do + filters = + Logic.build_search_filters(%{ + "category_id" => "", + "latitude" => "", + "longitude" => "" + }) + + assert filters.category_id == nil + assert filters.latitude == nil + assert filters.longitude == nil + end + end +end diff --git a/test/localspot/businesses/queries_test.exs b/test/localspot/businesses/queries_test.exs new file mode 100644 index 0000000..c54d7cc --- /dev/null +++ b/test/localspot/businesses/queries_test.exs @@ -0,0 +1,252 @@ +defmodule Localspot.Businesses.QueriesTest do + use Localspot.DataCase, async: true + + alias Localspot.Businesses.Queries + alias Localspot.Businesses.{Category, Business} + alias Localspot.Repo + + setup do + category = Repo.insert!(%Category{name: "Coffee", slug: "coffee"}) + + business = + Repo.insert!(%Business{ + name: "Test Coffee Shop", + slug: "test-coffee-shop", + description: "A great coffee shop", + street_address: "123 Main St", + city: "Test City", + state: "TC", + zip_code: "12345", + latitude: Decimal.new("40.7128"), + longitude: Decimal.new("-74.0060"), + locally_owned: true, + active: true, + category_id: category.id + }) + + %{category: category, business: business} + end + + describe "list_categories/0" do + test "returns all categories", %{category: category} do + categories = Queries.list_categories() + assert length(categories) >= 1 + assert Enum.any?(categories, &(&1.id == category.id)) + end + + test "orders categories by name" do + Repo.insert!(%Category{name: "Aardvark Services", slug: "aardvark"}) + Repo.insert!(%Category{name: "Zebra Goods", slug: "zebra"}) + + categories = Queries.list_categories() + names = Enum.map(categories, & &1.name) + + assert names == Enum.sort(names) + end + end + + describe "get_category/1" do + test "returns category by ID", %{category: category} do + found = Queries.get_category(category.id) + assert found.id == category.id + end + + test "returns nil for unknown ID" do + assert Queries.get_category(-1) == nil + end + end + + describe "get_category_by_slug/1" do + test "returns category by slug", %{category: category} do + found = Queries.get_category_by_slug(category.slug) + assert found.id == category.id + end + + test "returns nil for unknown slug" do + assert Queries.get_category_by_slug("unknown") == nil + end + end + + describe "list_businesses/1" do + test "returns active businesses", %{business: business} do + businesses = Queries.list_businesses(%{}) + assert Enum.any?(businesses, &(&1.id == business.id)) + end + + test "excludes inactive businesses", %{business: business, category: category} do + inactive = + Repo.insert!(%Business{ + name: "Inactive Shop", + slug: "inactive-shop", + street_address: "456 Oak Ave", + city: "Test City", + state: "TC", + zip_code: "12345", + active: false, + category_id: category.id + }) + + businesses = Queries.list_businesses(%{}) + assert Enum.any?(businesses, &(&1.id == business.id)) + refute Enum.any?(businesses, &(&1.id == inactive.id)) + end + + test "filters by search query", %{business: business} do + businesses = Queries.list_businesses(%{query: "Coffee"}) + assert Enum.any?(businesses, &(&1.id == business.id)) + + businesses = Queries.list_businesses(%{query: "nonexistent"}) + refute Enum.any?(businesses, &(&1.id == business.id)) + end + + test "filters by category", %{category: category, business: business} do + other_category = Repo.insert!(%Category{name: "Other", slug: "other"}) + + businesses = Queries.list_businesses(%{category_id: category.id}) + assert length(businesses) == 1 + assert hd(businesses).id == business.id + + businesses = Queries.list_businesses(%{category_id: other_category.id}) + assert businesses == [] + end + + test "filters by locally owned", %{business: business, category: category} do + chain = + Repo.insert!(%Business{ + name: "Chain Store", + slug: "chain-store", + street_address: "789 Corporate Blvd", + city: "Test City", + state: "TC", + zip_code: "12345", + locally_owned: false, + active: true, + category_id: category.id + }) + + # All businesses + businesses = Queries.list_businesses(%{locally_owned_only: false}) + assert length(businesses) == 2 + + # Only locally owned + businesses = Queries.list_businesses(%{locally_owned_only: true}) + assert length(businesses) == 1 + assert hd(businesses).id == business.id + refute Enum.any?(businesses, &(&1.id == chain.id)) + end + + test "filters by distance", %{business: business, category: category} do + # Business in LA (far from the test business in NYC area) + far_business = + Repo.insert!(%Business{ + name: "LA Coffee", + slug: "la-coffee", + street_address: "100 Hollywood Blvd", + city: "Los Angeles", + state: "CA", + zip_code: "90028", + latitude: Decimal.new("34.0522"), + longitude: Decimal.new("-118.2437"), + locally_owned: true, + active: true, + category_id: category.id + }) + + # Search near NYC with small radius + businesses = + Queries.list_businesses(%{ + latitude: 40.7128, + longitude: -74.0060, + radius_miles: 10.0 + }) + + assert Enum.any?(businesses, &(&1.id == business.id)) + refute Enum.any?(businesses, &(&1.id == far_business.id)) + + # Search near LA + businesses = + Queries.list_businesses(%{ + latitude: 34.0522, + longitude: -118.2437, + radius_miles: 10.0 + }) + + refute Enum.any?(businesses, &(&1.id == business.id)) + assert Enum.any?(businesses, &(&1.id == far_business.id)) + end + + test "adds distance_miles to results when searching by location", %{business: business} do + businesses = + Queries.list_businesses(%{ + latitude: 40.7128, + longitude: -74.0060, + radius_miles: 10.0 + }) + + found = Enum.find(businesses, &(&1.id == business.id)) + assert found.distance_miles != nil + assert found.distance_miles < 1.0 + end + + test "preloads category and photos", %{business: business} do + businesses = Queries.list_businesses(%{}) + found = Enum.find(businesses, &(&1.id == business.id)) + + assert Ecto.assoc_loaded?(found.category) + assert Ecto.assoc_loaded?(found.photos) + end + end + + describe "get_business_by_slug/1" do + test "returns business with preloads", %{business: business} do + found = Queries.get_business_by_slug(business.slug) + assert found.id == business.id + assert Ecto.assoc_loaded?(found.category) + assert Ecto.assoc_loaded?(found.hours) + assert Ecto.assoc_loaded?(found.photos) + end + + test "returns nil for unknown slug" do + assert Queries.get_business_by_slug("unknown") == nil + end + + test "returns nil for inactive business slug", %{category: category} do + inactive = + Repo.insert!(%Business{ + name: "Inactive", + slug: "inactive", + street_address: "123 St", + city: "City", + state: "ST", + zip_code: "12345", + active: false, + category_id: category.id + }) + + assert Queries.get_business_by_slug(inactive.slug) == nil + end + end + + describe "count_businesses_by_category/0" do + test "returns counts by category", %{category: category} do + counts = Queries.count_businesses_by_category() + assert Map.get(counts, category.id) == 1 + end + + test "excludes inactive businesses", %{category: category} do + Repo.insert!(%Business{ + name: "Inactive", + slug: "inactive2", + street_address: "123 St", + city: "City", + state: "ST", + zip_code: "12345", + active: false, + category_id: category.id + }) + + counts = Queries.count_businesses_by_category() + assert Map.get(counts, category.id) == 1 + end + end +end