berrypod/test/simpleshop_theme/search_test.exs
jamey 57c3ba0e28 wire shop LiveViews to DB queries and improve search UX
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>
2026-02-13 08:27:26 +00:00

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