FTS5 prefix matching misses mid-word substrings (e.g. "ebook" in "notebook"). When FTS5 returns zero results, fall back to LIKE query on title and category with proper wildcard escaping. 4 new tests, 757 total. Also marks completed plan files (search, admin-redesign, setup-wizard, products-context) with correct status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.2 KiB
Elixir
248 lines
7.2 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 FTS index
|
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search_map")
|
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search")
|
|
|
|
# Verify FTS index is empty
|
|
%{rows: rows} = SimpleshopTheme.Repo.query!("SELECT COUNT(*) FROM products_search_map")
|
|
assert rows == [[0]]
|
|
|
|
# Index just ocean
|
|
ocean = SimpleshopTheme.Repo.preload(ocean, [:variants])
|
|
Search.index_product(ocean)
|
|
|
|
# Verify ocean is now in the FTS index
|
|
%{rows: [[count]]} = SimpleshopTheme.Repo.query!("SELECT COUNT(*) FROM products_search_map")
|
|
assert count == 1
|
|
|
|
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 "LIKE fallback" do
|
|
test "finds substring matches that FTS5 prefix misses", %{ocean: ocean} do
|
|
# "ebook" is in the middle of "Notebook" — FTS5 prefix won't match
|
|
results = Search.search("ebook")
|
|
assert Enum.any?(results, &(&1.id == ocean.id))
|
|
end
|
|
|
|
test "falls back to category substring match", %{forest: forest} do
|
|
# "ppar" is a substring of "Apparel" — FTS5 prefix won't match
|
|
results = Search.search("pparel")
|
|
assert Enum.any?(results, &(&1.id == forest.id))
|
|
end
|
|
end
|
|
|
|
describe "remove_product/1" do
|
|
test "removes a product from the index", %{ocean: ocean} do
|
|
# Verify ocean is in the FTS index
|
|
%{rows: [[rowid]]} =
|
|
SimpleshopTheme.Repo.query!(
|
|
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
|
|
[ocean.id]
|
|
)
|
|
|
|
assert rowid
|
|
|
|
Search.remove_product(ocean.id)
|
|
|
|
# Verify it's gone from the FTS index
|
|
%{rows: rows} =
|
|
SimpleshopTheme.Repo.query!(
|
|
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
|
|
[ocean.id]
|
|
)
|
|
|
|
assert rows == []
|
|
end
|
|
|
|
test "is a no-op for unindexed product" do
|
|
assert Search.remove_product(-1) == :ok
|
|
end
|
|
end
|
|
end
|