add FTS5 full-text product search
Adds SQLite FTS5 search index with BM25 ranking across product title, category, variant attributes, and description. Search modal now has live results with thumbnails, prices, and click-to-navigate. Index rebuilds automatically after each provider sync. Also fixes Access syntax on Product/ProductImage structs (Map.get instead of bracket notation) which was causing crashes when real products were loaded from the database. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
199
test/simpleshop_theme/search_test.exs
Normal file
199
test/simpleshop_theme/search_test.exs
Normal file
@@ -0,0 +1,199 @@
|
||||
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
|
||||
end
|
||||
Reference in New Issue
Block a user