Add Businesses context with A-Frame architecture

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Sivic 2025-11-30 23:55:29 -05:00
parent 9cbbb045b9
commit e8fd635ddb
15 changed files with 1133 additions and 0 deletions

View file

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(mix compile:*)",
"Bash(mix test:*)",
"Bash(mix format:*)"
],
"deny": [],
"ask": []
}
}

155
lib/localspot/businesses.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

Binary file not shown.

View file

@ -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

View file

@ -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