diff --git a/lib/simpleshop_theme/search.ex b/lib/simpleshop_theme/search.ex new file mode 100644 index 0000000..a5ca60c --- /dev/null +++ b/lib/simpleshop_theme/search.ex @@ -0,0 +1,192 @@ +defmodule SimpleshopTheme.Search do + @moduledoc """ + Full-text product search backed by SQLite FTS5. + + Uses a contentless FTS5 index with BM25 ranking. The index is rebuilt + from the products table after each provider sync. + """ + + import Ecto.Query + + alias SimpleshopTheme.Products.Product + alias SimpleshopTheme.Repo + + @listing_preloads [images: :image] + + # BM25 column weights: title(10), category(5), variant_info(3), description(1) + @bm25_weights "10.0, 5.0, 3.0, 1.0" + + @doc """ + Searches products by query string. Returns ranked list of Product structs + with listing preloads, or empty list for blank/short queries. + """ + def search(query) when is_binary(query) do + query = String.trim(query) + + if String.length(query) < 2 do + [] + else + fts_query = build_fts_query(query) + search_fts(fts_query) + end + end + + def search(_), do: [] + + @doc """ + Rebuilds the entire FTS5 index from visible, active products. + """ + def rebuild_index do + Repo.transaction(fn -> + # Clear existing index data + Repo.query!("DELETE FROM products_search_map") + Repo.query!("DELETE FROM products_search") + + # Load all visible products with variants for indexing + products = + Product + |> where([p], p.visible == true and p.status == "active") + |> preload([:variants]) + |> Repo.all() + + Enum.each(products, &insert_into_index/1) + end) + end + + @doc """ + Indexes or reindexes a single product. + Removes existing entry first if present. + """ + def index_product(%Product{} = product) do + product = Repo.preload(product, [:variants], force: true) + remove_from_index(product.id) + insert_into_index(product) + end + + # Build an FTS5 MATCH query from user input. + # Strips special chars, splits into tokens, adds * prefix match to last token. + defp build_fts_query(input) do + tokens = + input + |> String.replace(~r/[^\w\s]/, "") + |> String.split(~r/\s+/, trim: true) + + case tokens do + [] -> + nil + + tokens -> + {complete, [last]} = Enum.split(tokens, -1) + + parts = + Enum.map(complete, &~s("#{&1}")) ++ + [~s("#{last}" *)] + + Enum.join(parts, " ") + end + end + + defp search_fts(nil), do: [] + + defp search_fts(fts_query) do + result = + Repo.query!( + """ + SELECT m.product_id, bm25(products_search, #{@bm25_weights}) AS rank + FROM products_search + JOIN products_search_map m ON m.rowid = products_search.rowid + WHERE products_search MATCH ?1 + ORDER BY rank + LIMIT 20 + """, + [fts_query] + ) + + product_ids = Enum.map(result.rows, fn [id, _rank] -> id end) + + if product_ids == [] do + [] + else + # Fetch full structs preserving rank order + products_by_id = + Product + |> where([p], p.id in ^product_ids) + |> where([p], p.visible == true and p.status == "active") + |> preload(^@listing_preloads) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + Enum.flat_map(product_ids, fn id -> + case Map.get(products_by_id, id) do + nil -> [] + product -> [product] + end + end) + end + end + + defp insert_into_index(%Product{} = product) do + # Insert mapping row + Repo.query!( + "INSERT INTO products_search_map (product_id) VALUES (?1)", + [product.id] + ) + + # Get the rowid just inserted + %{rows: [[rowid]]} = + Repo.query!( + "SELECT rowid FROM products_search_map WHERE product_id = ?1", + [product.id] + ) + + # Build index content + variant_info = build_variant_info(product.variants || []) + description = strip_html(product.description || "") + + Repo.query!( + """ + INSERT INTO products_search (rowid, title, category, variant_info, description) + VALUES (?1, ?2, ?3, ?4, ?5) + """, + [rowid, product.title || "", product.category || "", variant_info, description] + ) + end + + defp remove_from_index(product_id) do + # Get the rowid for this product (if indexed) + case Repo.query!( + "SELECT rowid FROM products_search_map WHERE product_id = ?1", + [product_id] + ) do + %{rows: [[rowid]]} -> + Repo.query!("DELETE FROM products_search WHERE rowid = ?1", [rowid]) + Repo.query!("DELETE FROM products_search_map WHERE rowid = ?1", [rowid]) + + _ -> + :ok + end + end + + # Build searchable variant text from enabled variants + defp build_variant_info(variants) do + variants + |> Enum.filter(& &1.is_enabled) + |> Enum.flat_map(fn v -> [v.title | Map.values(v.options || %{})] end) + |> Enum.uniq() + |> Enum.join(" ") + end + + # Strip HTML tags and decode common entities + defp strip_html(html) do + html + |> String.replace(~r/<[^>]+>/, " ") + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace(""", "\"") + |> String.replace("'", "'") + |> String.replace(" ", " ") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end +end diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex index df4f2cd..320256a 100644 --- a/lib/simpleshop_theme/sync/product_sync_worker.ex +++ b/lib/simpleshop_theme/sync/product_sync_worker.ex @@ -99,6 +99,10 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do Products.update_sync_status(conn, "completed", DateTime.utc_now()) product_count = Products.count_products_for_connection(conn.id) broadcast_sync(conn.id, {:sync_status, "completed", product_count}) + + # Rebuild search index after successful sync + SimpleshopTheme.Search.rebuild_index() + :ok else {:error, reason} = error -> diff --git a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex index 670b588..214e581 100644 --- a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page="cart" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<.page_title text="Your basket" /> diff --git a/lib/simpleshop_theme_web/components/page_templates/checkout_success.html.heex b/lib/simpleshop_theme_web/components/page_templates/checkout_success.html.heex index 8225ea5..e282657 100644 --- a/lib/simpleshop_theme_web/components/page_templates/checkout_success.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/checkout_success.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page="checkout" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<%= if @order && @order.payment_status == "paid" do %> diff --git a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex index bf971ea..2f3aa2e 100644 --- a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page="collection" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<.collection_header title="All Products" product_count={length(@preview_data.products)} /> diff --git a/lib/simpleshop_theme_web/components/page_templates/contact.html.heex b/lib/simpleshop_theme_web/components/page_templates/contact.html.heex index 89e8d8c..05b2366 100644 --- a/lib/simpleshop_theme_web/components/page_templates/contact.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/contact.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page="contact" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<.hero_section diff --git a/lib/simpleshop_theme_web/components/page_templates/content.html.heex b/lib/simpleshop_theme_web/components/page_templates/content.html.heex index 7ff3ab2..ff02300 100644 --- a/lib/simpleshop_theme_web/components/page_templates/content.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/content.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page={@active_page} + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<%= if assigns[:hero_background] do %> diff --git a/lib/simpleshop_theme_web/components/page_templates/error.html.heex b/lib/simpleshop_theme_web/components/page_templates/error.html.heex index d0da345..652aa9b 100644 --- a/lib/simpleshop_theme_web/components/page_templates/error.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/error.html.heex @@ -10,6 +10,8 @@ cart_status={assigns[:cart_status]} active_page="error" error_page + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<.hero_section diff --git a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex index 1f731fa..12a43e6 100644 --- a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex @@ -9,6 +9,8 @@ cart_drawer_open={assigns[:cart_drawer_open] || false} cart_status={assigns[:cart_status]} active_page="pdp" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >
<.breadcrumb diff --git a/lib/simpleshop_theme_web/components/shop_components/layout.ex b/lib/simpleshop_theme_web/components/shop_components/layout.ex index a48e96f..d006b4b 100644 --- a/lib/simpleshop_theme_web/components/shop_components/layout.ex +++ b/lib/simpleshop_theme_web/components/shop_components/layout.ex @@ -67,6 +67,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do attr :cart_status, :string, default: nil attr :active_page, :string, required: true attr :error_page, :boolean, default: false + attr :search_query, :string, default: "" + attr :search_results, :list, default: [] slot :inner_block, required: true @@ -106,7 +108,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do cart_status={@cart_status} /> - <.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} /> + <.search_modal + hint_text={~s(Try a search – e.g. "mountain" or "notebook")} + search_query={@search_query} + search_results={@search_results} + /> <.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} /> @@ -315,35 +321,49 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do end @doc """ - Renders the search modal overlay. - - This is a modal dialog for searching products. Currently provides - the UI shell; search functionality will be added later. + Renders the search modal overlay with live search results. ## Attributes - * `hint_text` - Optional. Hint text shown below the search input. - Defaults to nil (no hint shown). - - ## Examples - - <.search_modal /> - <.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" /> + * `hint_text` - Hint text shown when no query is entered. + * `search_query` - Current search query string. + * `search_results` - List of Product structs matching the query. """ attr :hint_text, :string, default: nil + attr :search_query, :string, default: "" + attr :search_results, :list, default: [] def search_modal(assigns) do + alias SimpleshopTheme.Cart + alias SimpleshopTheme.Products.{Product, ProductImage} + + assigns = + assign( + assigns, + :results_with_images, + Enum.map(assigns.search_results, fn product -> + image = Product.primary_image(product) + %{product: product, image_url: ProductImage.direct_url(image, 96)} + end) + ) + ~H""" """ diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex index f3c20a4..1774271 100644 --- a/lib/simpleshop_theme_web/components/shop_components/product.ex +++ b/lib/simpleshop_theme_web/components/shop_components/product.ex @@ -108,7 +108,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do <% end %> <%= if @has_hover_image do %>
@@ -143,7 +143,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do <% end %>
- <%= if @show_category && @product[:category] do %> + <%= if @show_category && Map.get(@product, :category) do %> <%= if @mode == :preview do %>

<% else %> <.link - navigate={"/products/#{@product[:slug] || @product[:id]}"} + navigate={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"} class="stretched-link" style="color: inherit; text-decoration: none;" > @@ -212,11 +212,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do is_nil(image) -> {nil, nil} - image[:image_id] -> + Map.get(image, :image_id) -> {"/images/#{image.image_id}/variant/", ProductImage.source_width(image)} - image[:src] -> - {image[:src], ProductImage.source_width(image)} + Map.get(image, :src) -> + {Map.get(image, :src), ProductImage.source_width(image)} true -> {nil, nil} @@ -285,9 +285,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do <%= cond do %> <% Map.get(@product, :in_stock, true) == false -> %> Sold out - <% @product[:is_new] -> %> + <% Map.get(@product, :is_new) -> %> New - <% @product[:on_sale] -> %> + <% Map.get(@product, :on_sale) -> %> Sale <% true -> %> <% end %> diff --git a/lib/simpleshop_theme_web/live/admin/theme/index.ex b/lib/simpleshop_theme_web/live/admin/theme/index.ex index 128273d..4ffdcac 100644 --- a/lib/simpleshop_theme_web/live/admin/theme/index.ex +++ b/lib/simpleshop_theme_web/live/admin/theme/index.ex @@ -337,8 +337,8 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do defp preview_page(%{page: :pdp} = assigns) do product = List.first(assigns.preview_data.products) - option_types = product[:option_types] || [] - variants = product[:variants] || [] + option_types = Map.get(product, :option_types) || [] + variants = Map.get(product, :variants) || [] {selected_options, selected_variant} = case variants do @@ -468,7 +468,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do defp build_gallery_images(product) do alias SimpleshopTheme.Products.ProductImage - (product[:images] || []) + (Map.get(product, :images) || []) |> Enum.sort_by(& &1.position) |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end) |> Enum.reject(&is_nil/1) diff --git a/lib/simpleshop_theme_web/live/shop/collection.ex b/lib/simpleshop_theme_web/live/shop/collection.ex index e16a651..a9ebbfb 100644 --- a/lib/simpleshop_theme_web/live/shop/collection.ex +++ b/lib/simpleshop_theme_web/live/shop/collection.ex @@ -101,6 +101,8 @@ defmodule SimpleshopThemeWeb.Shop.Collection do cart_drawer_open={@cart_drawer_open} cart_status={assigns[:cart_status]} active_page="collection" + search_query={assigns[:search_query] || ""} + search_results={assigns[:search_results] || []} >

<.collection_header diff --git a/lib/simpleshop_theme_web/live/shop/product_show.ex b/lib/simpleshop_theme_web/live/shop/product_show.ex index 2589340..a3cde59 100644 --- a/lib/simpleshop_theme_web/live/shop/product_show.ex +++ b/lib/simpleshop_theme_web/live/shop/product_show.ex @@ -20,14 +20,14 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do # Build gallery images from local image_id or external URL gallery_images = - (product[:images] || []) + (Map.get(product, :images) || []) |> Enum.sort_by(& &1.position) |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end) |> Enum.reject(&is_nil/1) # Initialize variant selection option_types = Product.option_types(product) - variants = product[:variants] || [] + variants = Map.get(product, :variants) || [] {selected_options, selected_variant} = initialize_variant_selection(variants) available_options = compute_available_options(option_types, variants, selected_options) display_price = variant_price(selected_variant, product) diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index d0769be..9c25643 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -50,7 +50,8 @@ defmodule SimpleshopThemeWeb.Router do {SimpleshopThemeWeb.UserAuth, :mount_current_scope}, {SimpleshopThemeWeb.ThemeHook, :mount_theme}, {SimpleshopThemeWeb.ThemeHook, :require_site_live}, - {SimpleshopThemeWeb.CartHook, :mount_cart} + {SimpleshopThemeWeb.CartHook, :mount_cart}, + {SimpleshopThemeWeb.SearchHook, :mount_search} ] do live "/", Shop.Home, :index live "/about", Shop.Content, :about diff --git a/lib/simpleshop_theme_web/search_hook.ex b/lib/simpleshop_theme_web/search_hook.ex new file mode 100644 index 0000000..76d8f90 --- /dev/null +++ b/lib/simpleshop_theme_web/search_hook.ex @@ -0,0 +1,49 @@ +defmodule SimpleshopThemeWeb.SearchHook do + @moduledoc """ + LiveView on_mount hook for product search. + + Mounted in the public_shop live_session to give all shop LiveViews + search state and shared event handlers via attach_hook. + + Handles these events: + - `search` - run FTS5 search with debounced query + - `clear_search` - reset search state + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [attach_hook: 4] + + alias SimpleshopTheme.Search + + def on_mount(:mount_search, _params, _session, socket) do + socket = + socket + |> assign(:search_query, "") + |> assign(:search_results, []) + |> attach_hook(:search_events, :handle_event, &handle_search_event/3) + + {:cont, socket} + end + + defp handle_search_event("search", %{"value" => query}, socket) do + results = Search.search(query) + + socket = + socket + |> assign(:search_query, query) + |> assign(:search_results, results) + + {:halt, socket} + end + + defp handle_search_event("clear_search", _params, socket) do + socket = + socket + |> assign(:search_query, "") + |> assign(:search_results, []) + + {:halt, socket} + end + + defp handle_search_event(_event, _params, socket), do: {:cont, socket} +end diff --git a/priv/repo/migrations/20260213020000_create_products_search.exs b/priv/repo/migrations/20260213020000_create_products_search.exs new file mode 100644 index 0000000..2ecaaee --- /dev/null +++ b/priv/repo/migrations/20260213020000_create_products_search.exs @@ -0,0 +1,29 @@ +defmodule SimpleshopTheme.Repo.Migrations.CreateProductsSearch do + use Ecto.Migration + + def up do + # Rowid mapping (FTS5 needs integer rowids, products use UUIDs) + execute """ + CREATE TABLE products_search_map ( + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + product_id TEXT NOT NULL UNIQUE + ) + """ + + # FTS5 virtual table — stores its own content for simple delete/reindex + execute """ + CREATE VIRTUAL TABLE products_search USING fts5( + title, + category, + variant_info, + description, + tokenize='unicode61 remove_diacritics 2' + ) + """ + end + + def down do + execute "DROP TABLE IF EXISTS products_search" + execute "DROP TABLE IF EXISTS products_search_map" + end +end diff --git a/test/simpleshop_theme/search_test.exs b/test/simpleshop_theme/search_test.exs new file mode 100644 index 0000000..f4f863c --- /dev/null +++ b/test/simpleshop_theme/search_test.exs @@ -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: "

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 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