Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
6.0 KiB
Elixir
214 lines
6.0 KiB
Elixir
defmodule SimpleshopTheme.SearchTest do
|
|
use SimpleshopTheme.DataCase, async: false
|
|
|
|
alias SimpleshopTheme.Search
|
|
|
|
import SimpleshopTheme.ProductsFixtures
|
|
|
|
setup do
|
|
conn = provider_connection_fixture()
|
|
|
|
mountain =
|
|
product_fixture(%{
|
|
provider_connection: conn,
|
|
title: "Mountain Sunrise Art Print",
|
|
description: "<p>A beautiful <strong>mountain</strong> landscape at dawn.</p>",
|
|
category: "Art Prints"
|
|
})
|
|
|
|
product_variant_fixture(%{
|
|
product: mountain,
|
|
title: "Small / Navy",
|
|
options: %{"Size" => "Small", "Color" => "Navy"},
|
|
price: 1999
|
|
})
|
|
|
|
ocean =
|
|
product_fixture(%{
|
|
provider_connection: conn,
|
|
title: "Ocean Waves Notebook",
|
|
description: "A spiral-bound notebook with ocean wave cover art.",
|
|
category: "Stationery"
|
|
})
|
|
|
|
product_variant_fixture(%{
|
|
product: ocean,
|
|
title: "A5",
|
|
options: %{"Size" => "A5"},
|
|
price: 1299
|
|
})
|
|
|
|
forest =
|
|
product_fixture(%{
|
|
provider_connection: conn,
|
|
title: "Forest Silhouette T-Shirt",
|
|
description: "Cotton t-shirt with forest silhouette print.",
|
|
category: "Apparel"
|
|
})
|
|
|
|
product_variant_fixture(%{
|
|
product: forest,
|
|
title: "Large / Black",
|
|
options: %{"Size" => "Large", "Color" => "Black"},
|
|
price: 2999
|
|
})
|
|
|
|
product_variant_fixture(%{
|
|
product: forest,
|
|
title: "Medium / Navy Blue",
|
|
options: %{"Size" => "Medium", "Color" => "Navy Blue"},
|
|
price: 2999
|
|
})
|
|
|
|
# Hidden product — should not appear in search
|
|
_hidden =
|
|
product_fixture(%{
|
|
provider_connection: conn,
|
|
title: "Hidden Mountain Poster",
|
|
description: "This should not be searchable.",
|
|
category: "Art Prints",
|
|
visible: false
|
|
})
|
|
|
|
Search.rebuild_index()
|
|
|
|
%{mountain: mountain, ocean: ocean, forest: forest}
|
|
end
|
|
|
|
describe "search/1" do
|
|
test "finds products by title", %{mountain: mountain} do
|
|
results = Search.search("mountain")
|
|
assert length(results) >= 1
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
end
|
|
|
|
test "finds products by category", %{mountain: mountain, ocean: ocean} do
|
|
results = Search.search("art prints")
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
refute Enum.any?(results, &(&1.id == ocean.id))
|
|
end
|
|
|
|
test "finds products by variant attributes", %{forest: forest} do
|
|
results = Search.search("navy")
|
|
assert Enum.any?(results, &(&1.id == forest.id))
|
|
end
|
|
|
|
test "finds products by description", %{mountain: mountain} do
|
|
results = Search.search("landscape")
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
end
|
|
|
|
test "prefix matching on last token", %{mountain: mountain} do
|
|
results = Search.search("mou")
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
end
|
|
|
|
test "multi-word query matches all tokens", %{mountain: mountain} do
|
|
results = Search.search("mountain sunrise")
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
end
|
|
|
|
test "returns empty list for blank query" do
|
|
assert Search.search("") == []
|
|
assert Search.search(" ") == []
|
|
end
|
|
|
|
test "returns empty list for single character" do
|
|
assert Search.search("a") == []
|
|
end
|
|
|
|
test "returns empty list for nil" do
|
|
assert Search.search(nil) == []
|
|
end
|
|
|
|
test "returns empty list for no matches" do
|
|
assert Search.search("xyznonexistent") == []
|
|
end
|
|
|
|
test "special characters don't crash" do
|
|
assert Search.search("mountain's") == Search.search("mountains")
|
|
assert Search.search("test & foo") == []
|
|
assert Search.search("(brackets)") == []
|
|
end
|
|
|
|
test "hidden products are excluded" do
|
|
results = Search.search("hidden")
|
|
assert results == []
|
|
end
|
|
|
|
test "strips HTML from descriptions", %{mountain: mountain} do
|
|
results = Search.search("landscape dawn")
|
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
|
end
|
|
|
|
test "results include listing preloads", %{mountain: mountain} do
|
|
[result | _] = Search.search("mountain sunrise")
|
|
assert result.id == mountain.id
|
|
assert Ecto.assoc_loaded?(result.images)
|
|
end
|
|
|
|
test "title matches rank higher than description matches" do
|
|
results = Search.search("mountain")
|
|
# Mountain Sunrise Art Print (title match) should rank above
|
|
# Forest T-Shirt if it happened to mention "mountain" in description
|
|
first = List.first(results)
|
|
assert first.title =~ "Mountain"
|
|
end
|
|
end
|
|
|
|
describe "rebuild_index/0" do
|
|
test "rebuilds from scratch" do
|
|
# Verify search works before rebuild
|
|
assert length(Search.search("mountain")) >= 1
|
|
|
|
# Rebuild and verify still works
|
|
Search.rebuild_index()
|
|
assert length(Search.search("mountain")) >= 1
|
|
end
|
|
end
|
|
|
|
describe "index_product/1" do
|
|
test "indexes a single product", %{ocean: ocean} do
|
|
# Clear and rebuild without ocean
|
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search_map")
|
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search")
|
|
|
|
assert Search.search("ocean") == []
|
|
|
|
# Index just ocean
|
|
ocean = SimpleshopTheme.Repo.preload(ocean, [:variants])
|
|
Search.index_product(ocean)
|
|
|
|
results = Search.search("ocean")
|
|
assert length(results) == 1
|
|
assert hd(results).id == ocean.id
|
|
end
|
|
|
|
test "reindexing updates existing entry", %{mountain: mountain} do
|
|
# Change title and reindex
|
|
mountain =
|
|
mountain
|
|
|> Ecto.Changeset.change(title: "Alpine Sunrise Art Print")
|
|
|> SimpleshopTheme.Repo.update!()
|
|
|
|
Search.index_product(mountain)
|
|
|
|
assert Search.search("alpine") != []
|
|
end
|
|
end
|
|
|
|
describe "remove_product/1" do
|
|
test "removes a product from the index", %{ocean: ocean} do
|
|
assert Search.search("ocean") != []
|
|
|
|
Search.remove_product(ocean.id)
|
|
|
|
assert Search.search("ocean") == []
|
|
end
|
|
|
|
test "is a no-op for unindexed product" do
|
|
assert Search.remove_product(-1) == :ok
|
|
end
|
|
end
|
|
end
|