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: "
A beautiful mountain landscape at dawn.
", 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