simpleshop_theme/test/simpleshop_theme/search_test.exs
jamey edcbc596e3 add LIKE substring fallback to search and update plan statuses
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>
2026-02-13 09:09:10 +00:00

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