From 16a28ac01ca7a6392691dfba5a6eb983a08df78e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Mar 2026 16:28:04 -0500 Subject: [PATCH] Add Target Sports USA scraper, product filtering, and subsonic detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add subsonic boolean field to products with composite filter indexes - Add TextDetector utility for detecting subsonic/casing from product titles - Wire TextDetector into SgAmmo (casing + subsonic) and LuckyGunner (subsonic + fallback casing) - Extend latest_prices_for_caliber with casing, grain_weight, and subsonic filter options - Add distinct_grain_weights_for_caliber and distinct_casings_for_caliber queries - Add filter UI with toggleable casing, grain weight, and subsonic buttons - Add grain weight column and subsonic badge to product table - Refresh filter options on PubSub price updates for real-time UI - Implement Target Sports USA scraper with 18 caliber mappings - Display all prices in dollar format ($0.38 instead of 38¢) --- lib/ammoprices/catalog.ex | 33 ++- lib/ammoprices/catalog/product.ex | 2 + lib/ammoprices/prices.ex | 20 ++ .../scraping/retailers/lucky_gunner.ex | 5 +- lib/ammoprices/scraping/retailers/sg_ammo.ex | 5 +- .../scraping/retailers/target_sports_usa.ex | 191 +++++++++++++++++ lib/ammoprices/scraping/runner.ex | 1 + lib/ammoprices/scraping/scrape_job.ex | 3 +- lib/ammoprices/scraping/text_detector.ex | 20 ++ .../components/price_components.ex | 6 +- lib/ammoprices_web/live/caliber_live/show.ex | 194 +++++++++++++++--- ...211428_add_subsonic_and_filter_indexes.exs | 13 ++ priv/repo/seeds.exs | 5 + test/ammoprices/catalog_test.exs | 97 +++++++++ test/ammoprices/prices_test.exs | 95 +++++++++ .../scraping/retailers/lucky_gunner_test.exs | 19 +- .../scraping/retailers/sg_ammo_test.exs | 19 +- .../retailers/target_sports_usa_test.exs | 118 +++++++++++ test/ammoprices/scraping/runner_test.exs | 6 +- test/ammoprices/scraping/scrape_job_test.exs | 7 + .../scraping/text_detector_test.exs | 69 +++++++ .../live/caliber_live/show_test.exs | 99 +++++++++ test/fixtures/lucky_gunner/9mm.html | 34 +++ test/fixtures/sg_ammo/9mm.html | 23 +++ test/fixtures/target_sports_usa/9mm.html | 61 ++++++ test/support/fixtures.ex | 1 + 26 files changed, 1105 insertions(+), 41 deletions(-) create mode 100644 lib/ammoprices/scraping/retailers/target_sports_usa.ex create mode 100644 lib/ammoprices/scraping/text_detector.ex create mode 100644 priv/repo/migrations/20260311211428_add_subsonic_and_filter_indexes.exs create mode 100644 test/ammoprices/scraping/retailers/target_sports_usa_test.exs create mode 100644 test/ammoprices/scraping/text_detector_test.exs create mode 100644 test/fixtures/target_sports_usa/9mm.html diff --git a/lib/ammoprices/catalog.ex b/lib/ammoprices/catalog.ex index 816f7f5..93105d7 100644 --- a/lib/ammoprices/catalog.ex +++ b/lib/ammoprices/catalog.ex @@ -67,7 +67,18 @@ defmodule Ammoprices.Catalog do |> Repo.insert( on_conflict: {:replace, - [:title, :brand, :grain_weight, :round_count, :casing, :condition, :in_stock, :last_seen_at, :updated_at]}, + [ + :title, + :brand, + :grain_weight, + :round_count, + :casing, + :condition, + :in_stock, + :subsonic, + :last_seen_at, + :updated_at + ]}, conflict_target: [:retailer_id, :url], returning: true ) @@ -81,4 +92,24 @@ defmodule Ammoprices.Catalog do |> limit(^limit) |> Repo.all() end + + def distinct_grain_weights_for_caliber(caliber_id) do + Product + |> where([p], p.caliber_id == ^caliber_id) + |> where([p], not is_nil(p.grain_weight)) + |> distinct(true) + |> select([p], p.grain_weight) + |> order_by([p], asc: p.grain_weight) + |> Repo.all() + end + + def distinct_casings_for_caliber(caliber_id) do + Product + |> where([p], p.caliber_id == ^caliber_id) + |> where([p], not is_nil(p.casing)) + |> distinct(true) + |> select([p], p.casing) + |> order_by([p], asc: p.casing) + |> Repo.all() + end end diff --git a/lib/ammoprices/catalog/product.ex b/lib/ammoprices/catalog/product.ex index 142be63..de47779 100644 --- a/lib/ammoprices/catalog/product.ex +++ b/lib/ammoprices/catalog/product.ex @@ -20,6 +20,7 @@ defmodule Ammoprices.Catalog.Product do field :upc, :string field :external_id, :string field :in_stock, :boolean, default: true + field :subsonic, :boolean, default: false field :last_seen_at, :utc_datetime has_many :price_snapshots, Ammoprices.Prices.PriceSnapshot @@ -40,6 +41,7 @@ defmodule Ammoprices.Catalog.Product do :upc, :external_id, :in_stock, + :subsonic, :last_seen_at ]) |> validate_required([:title, :url]) diff --git a/lib/ammoprices/prices.ex b/lib/ammoprices/prices.ex index 4b1f42f..c7cb0d2 100644 --- a/lib/ammoprices/prices.ex +++ b/lib/ammoprices/prices.ex @@ -22,6 +22,9 @@ defmodule Ammoprices.Prices do def latest_prices_for_caliber(caliber_id, opts \\ []) do limit = Keyword.get(opts, :limit, 100) in_stock = Keyword.get(opts, :in_stock) + casing = Keyword.get(opts, :casing) + grain_weight = Keyword.get(opts, :grain_weight) + subsonic = Keyword.get(opts, :subsonic) latest_per_product = from(ps in PriceSnapshot, @@ -30,6 +33,9 @@ defmodule Ammoprices.Prices do distinct: ps.product_id, order_by: [asc: ps.product_id, desc: ps.recorded_at] ) + |> maybe_filter_product(:casing, casing) + |> maybe_filter_product(:grain_weight, grain_weight) + |> maybe_filter_product(:subsonic, subsonic) query = from(ps in subquery(latest_per_product), @@ -47,6 +53,20 @@ defmodule Ammoprices.Prices do Repo.all(query) end + defp maybe_filter_product(query, _field, nil), do: query + + defp maybe_filter_product(query, :casing, value) do + where(query, [_ps, p], p.casing == ^value) + end + + defp maybe_filter_product(query, :grain_weight, value) do + where(query, [_ps, p], p.grain_weight == ^value) + end + + defp maybe_filter_product(query, :subsonic, value) do + where(query, [_ps, p], p.subsonic == ^value) + end + @doc """ Returns daily aggregates (avg, min, max) of price_per_round_cents for in-stock products of a caliber. diff --git a/lib/ammoprices/scraping/retailers/lucky_gunner.ex b/lib/ammoprices/scraping/retailers/lucky_gunner.ex index d0218e3..7ef86b8 100644 --- a/lib/ammoprices/scraping/retailers/lucky_gunner.ex +++ b/lib/ammoprices/scraping/retailers/lucky_gunner.ex @@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do @moduledoc false @behaviour Ammoprices.Scraping.Scraper + alias Ammoprices.Scraping.TextDetector + @slug_to_path %{ "9mm-luger" => "/handgun/9mm-ammo", "45-acp" => "/handgun/45-acp-ammo", @@ -55,7 +57,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do brand: extract_brand(desc_items), grain_weight: extract_grain_weight(desc_items), round_count: extract_round_count(title), - casing: extract_casing(desc_items), + casing: extract_casing(desc_items) || TextDetector.detect_casing(title), + subsonic: TextDetector.detect_subsonic(title), in_stock: extract_in_stock(item) } else diff --git a/lib/ammoprices/scraping/retailers/sg_ammo.ex b/lib/ammoprices/scraping/retailers/sg_ammo.ex index f0e2d6c..9cd6a37 100644 --- a/lib/ammoprices/scraping/retailers/sg_ammo.ex +++ b/lib/ammoprices/scraping/retailers/sg_ammo.ex @@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do @moduledoc false @behaviour Ammoprices.Scraping.Scraper + alias Ammoprices.Scraping.TextDetector + @slug_to_path %{ "9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo", "45-acp" => "/catalog/pistol-ammo-for-sale/45-auto-acp-ammo", @@ -53,7 +55,8 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do brand: extract_brand(title), grain_weight: extract_grain_weight(title), round_count: extract_round_count(title), - casing: nil, + casing: TextDetector.detect_casing(title), + subsonic: TextDetector.detect_subsonic(title), in_stock: extract_in_stock(row) } else diff --git a/lib/ammoprices/scraping/retailers/target_sports_usa.ex b/lib/ammoprices/scraping/retailers/target_sports_usa.ex new file mode 100644 index 0000000..62a751a --- /dev/null +++ b/lib/ammoprices/scraping/retailers/target_sports_usa.ex @@ -0,0 +1,191 @@ +defmodule Ammoprices.Scraping.Retailers.TargetSportsUsa do + @moduledoc false + @behaviour Ammoprices.Scraping.Scraper + + alias Ammoprices.Scraping.TextDetector + + @slug_to_path %{ + "9mm-luger" => "/9mm-luger-ammo-c-51.aspx", + "45-acp" => "/45-acp-auto-ammo-c-70.aspx", + "380-acp" => "/380-acp-auto-ammo-c-50.aspx", + "40-sw" => "/40-sw-ammo-c-59.aspx", + "38-special" => "/38-special-ammo-c-56.aspx", + "357-magnum" => "/357-magnum-ammo-c-57.aspx", + "10mm-auto" => "/10mm-auto-ammo-c-60.aspx", + "556-223" => "/223-remington-ammo-c-83.aspx", + "308-win" => "/308-winchester-ammo-c-101.aspx", + "762x39" => "/762x39mm-ammo-c-108.aspx", + "30-06" => "/30-06-springfield-ammo-c-105.aspx", + "300-blackout" => "/300-aac-blackout-ammo-c-969.aspx", + "65-creedmoor" => "/65-creedmoor-ammo-c-776.aspx", + "22-lr" => "/22-long-rifle-ammo-c-202.aspx", + "22-wmr" => "/22-wmr-ammo-c-203.aspx", + "17-hmr" => "/17-hmr-ammo-c-198.aspx", + "12-gauge" => "/12-gauge-shotgun-ammo-c-747.aspx", + "20-gauge" => "/20-gauge-shotgun-ammo-c-749.aspx" + } + + @impl true + def retailer_slug, do: "target-sports-usa" + + @impl true + def category_url(caliber) do + Map.get(@slug_to_path, caliber.slug) + end + + @impl true + def parse_products(html) do + html + |> Floki.parse_document!() + |> Floki.find("ul.product-list > li") + |> Enum.map(&parse_item/1) + |> Enum.reject(&is_nil/1) + end + + defp parse_item(item) do + with {:ok, title} <- extract_title(item), + {:ok, url} <- extract_url(item), + {:ok, price_cents} <- extract_price(item), + {:ok, ppr_cents} <- extract_price_per_round(item) do + %{ + title: title, + url: url, + price_cents: price_cents, + price_per_round_cents: ppr_cents, + brand: extract_brand(item), + grain_weight: extract_grain_weight(title), + round_count: extract_round_count(title), + casing: TextDetector.detect_casing(title), + subsonic: TextDetector.detect_subsonic(title), + in_stock: extract_in_stock(item) + } + else + _ -> nil + end + end + + defp extract_title(item) do + case Floki.find(item, "h2") do + [{_, _, _children} = h2] -> + # Full h2 text minus the brand text + full_text = h2 |> Floki.text() |> String.trim() + + brand_text = + case Floki.find(item, "h2 > strong") do + [{_, _, _} = strong] -> strong |> Floki.text() |> String.trim() + _ -> "" + end + + title = + full_text + |> String.replace(brand_text, "") + |> String.trim() + + if title == "", do: :error, else: {:ok, title} + + _ -> + :error + end + end + + defp extract_url(item) do + case Floki.find(item, "a") do + [{_, attrs, _} | _] -> + case List.keyfind(attrs, "href", 0) do + {"href", href} -> {:ok, href} + _ -> :error + end + + _ -> + :error + end + end + + defp extract_price(item) do + case Floki.find(item, ".product-listing-price") do + [{_, _, _} = node | _] -> + text = node |> Floki.text() |> String.trim() + + case Regex.run(~r/\$([0-9,]+\.\d{2})/, text) do + [_, amount] -> + cents = + amount + |> String.replace(",", "") + |> String.to_float() + |> Kernel.*(100) + |> round() + + {:ok, cents} + + _ -> + :error + end + + _ -> + :error + end + end + + defp extract_price_per_round(item) do + case Floki.find(item, ".product-listing-price span") do + [{_, _, _} = node | _] -> + text = node |> Floki.text() |> String.trim() + + case Regex.run(~r/\$([0-9.]+)\s*Per Round/i, text) do + [_, amount] -> + cents = + amount + |> String.to_float() + |> Kernel.*(100) + |> round() + + {:ok, cents} + + _ -> + :error + end + + _ -> + :error + end + end + + defp extract_brand(item) do + case Floki.find(item, "h2 > strong") do + [{_, _, _} = strong] -> + strong + |> Floki.text() + |> String.trim() + |> String.replace(~r/\s*(Ammunition|Ammo)\s*$/i, "") + |> String.trim() + + _ -> + nil + end + end + + defp extract_grain_weight(title) do + case Regex.run(~r/(\d+)\s*[Gg]rain/i, title) do + [_, weight] -> String.to_integer(weight) + _ -> nil + end + end + + defp extract_round_count(title) do + case Regex.run(~r/(\d[\d,]*)\s*[Rr]ounds?/i, title) do + [_, count] -> count |> String.replace(",", "") |> String.to_integer() + _ -> nil + end + end + + defp extract_in_stock(item) do + case Floki.find(item, ".add-to-cart") do + [{_, _, _} = node] -> + text = node |> Floki.text() |> String.trim() + text == "Add To Cart" + + _ -> + false + end + end +end diff --git a/lib/ammoprices/scraping/runner.ex b/lib/ammoprices/scraping/runner.ex index fedc33f..7ab03da 100644 --- a/lib/ammoprices/scraping/runner.ex +++ b/lib/ammoprices/scraping/runner.ex @@ -35,6 +35,7 @@ defmodule Ammoprices.Scraping.Runner do casing: product_data.casing, condition: "new", in_stock: product_data.in_stock, + subsonic: Map.get(product_data, :subsonic, false), last_seen_at: now } diff --git a/lib/ammoprices/scraping/scrape_job.ex b/lib/ammoprices/scraping/scrape_job.ex index a4afaa4..39d5a32 100644 --- a/lib/ammoprices/scraping/scrape_job.ex +++ b/lib/ammoprices/scraping/scrape_job.ex @@ -7,7 +7,8 @@ defmodule Ammoprices.Scraping.ScrapeJob do @scrapers [ Ammoprices.Scraping.Retailers.LuckyGunner, - Ammoprices.Scraping.Retailers.SgAmmo + Ammoprices.Scraping.Retailers.SgAmmo, + Ammoprices.Scraping.Retailers.TargetSportsUsa ] @impl Oban.Worker diff --git a/lib/ammoprices/scraping/text_detector.ex b/lib/ammoprices/scraping/text_detector.ex new file mode 100644 index 0000000..95e4103 --- /dev/null +++ b/lib/ammoprices/scraping/text_detector.ex @@ -0,0 +1,20 @@ +defmodule Ammoprices.Scraping.TextDetector do + @moduledoc false + + def detect_subsonic(nil), do: false + def detect_subsonic(title), do: Regex.match?(~r/sub[\-\s]?sonic/i, title) + + def detect_casing(nil), do: nil + + def detect_casing(title) do + downcased = String.downcase(title) + + cond do + String.contains?(downcased, "steel") -> "steel" + String.contains?(downcased, "aluminum") -> "aluminum" + String.contains?(downcased, "nickel") -> "brass" + String.contains?(downcased, "brass") -> "brass" + true -> nil + end + end +end diff --git a/lib/ammoprices_web/components/price_components.ex b/lib/ammoprices_web/components/price_components.ex index ed0b14d..bf1a5e0 100644 --- a/lib/ammoprices_web/components/price_components.ex +++ b/lib/ammoprices_web/components/price_components.ex @@ -68,12 +68,8 @@ defmodule AmmopricesWeb.PriceComponents do defp format_cpr(nil), do: "--" - defp format_cpr(cents) when cents < 100 do - "#{cents}¢/rd" - end - defp format_cpr(cents) do dollars = cents / 100 - "$#{:erlang.float_to_binary(dollars, decimals: 2)}/rd" + "$#{:erlang.float_to_binary(dollars, decimals: 2)}" end end diff --git a/lib/ammoprices_web/live/caliber_live/show.ex b/lib/ammoprices_web/live/caliber_live/show.ex index 951b85d..0ffe7b9 100644 --- a/lib/ammoprices_web/live/caliber_live/show.ex +++ b/lib/ammoprices_web/live/caliber_live/show.ex @@ -29,8 +29,12 @@ defmodule AmmopricesWeb.CaliberLive.Show do page_title: caliber.name, caliber: caliber, in_stock_filter: true, + casing_filter: nil, + grain_weight_filter: nil, + subsonic_filter: nil, chart_range: "30d" ) + |> load_filter_options() |> load_products() |> load_chart_data() |> load_price_stats() @@ -48,6 +52,46 @@ defmodule AmmopricesWeb.CaliberLive.Show do {:noreply, socket} end + @impl true + def handle_event("filter_casing", %{"casing" => casing}, socket) do + current = socket.assigns.casing_filter + new_value = if current == casing, do: nil, else: casing + + socket = + socket + |> assign(:casing_filter, new_value) + |> load_products() + + {:noreply, socket} + end + + @impl true + def handle_event("filter_grain_weight", %{"weight" => weight}, socket) do + current = socket.assigns.grain_weight_filter + weight_int = String.to_integer(weight) + new_value = if current == weight_int, do: nil, else: weight_int + + socket = + socket + |> assign(:grain_weight_filter, new_value) + |> load_products() + + {:noreply, socket} + end + + @impl true + def handle_event("toggle_subsonic_filter", _params, socket) do + current = socket.assigns.subsonic_filter + new_value = if current == true, do: nil, else: true + + socket = + socket + |> assign(:subsonic_filter, new_value) + |> load_products() + + {:noreply, socket} + end + @impl true def handle_event("change_range", %{"range" => range}, socket) do socket = @@ -62,6 +106,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do def handle_info({:prices_updated, _}, socket) do socket = socket + |> load_filter_options() |> load_products() |> load_chart_data() |> load_price_stats() @@ -69,19 +114,32 @@ defmodule AmmopricesWeb.CaliberLive.Show do {:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})} end - defp load_products(socket) do - %{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns + defp load_filter_options(socket) do + caliber_id = socket.assigns.caliber.id - opts = - if in_stock_filter do - [in_stock: true, limit: 200] - else - [limit: 200] - end + assign(socket, + available_casings: Catalog.distinct_casings_for_caliber(caliber_id), + available_grain_weights: Catalog.distinct_grain_weights_for_caliber(caliber_id) + ) + end + + defp load_products(socket) do + %{ + caliber: caliber, + in_stock_filter: in_stock_filter, + casing_filter: casing_filter, + grain_weight_filter: grain_weight_filter, + subsonic_filter: subsonic_filter + } = socket.assigns + + opts = [limit: 200] + opts = if in_stock_filter, do: Keyword.put(opts, :in_stock, true), else: opts + opts = if casing_filter, do: Keyword.put(opts, :casing, casing_filter), else: opts + opts = if grain_weight_filter, do: Keyword.put(opts, :grain_weight, grain_weight_filter), else: opts + opts = if subsonic_filter, do: Keyword.put(opts, :subsonic, true), else: opts snapshots = Prices.latest_prices_for_caliber(caliber.id, opts) - # Preload product+retailer for each snapshot product_ids = Enum.map(snapshots, & &1.product_id) products_map = @@ -136,10 +194,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do defp format_stat_cpr(nil), do: "--" - defp format_stat_cpr(cents) when cents < 100 do - "#{cents}\u00A2" - end - defp format_stat_cpr(cents) do "$#{:erlang.float_to_binary(cents / 100, decimals: 2)}" end @@ -235,7 +289,8 @@ defmodule AmmopricesWeb.CaliberLive.Show do <%!-- Filters --%> -
+
+ <%!-- In Stock toggle --%> + + <%!-- Subsonic toggle --%> + + + <%!-- Casing filter buttons --%> + <%= if @available_casings != [] do %> + | + + <% end %> + + <%!-- Grain weight filter buttons --%> + <%= if @available_grain_weights != [] do %> + | + + <% end %>
<%!-- Product Table --%> @@ -271,6 +386,9 @@ defmodule AmmopricesWeb.CaliberLive.Show do Price + + Grain + Rounds @@ -289,16 +407,32 @@ defmodule AmmopricesWeb.CaliberLive.Show do class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors" > - - {row.product.title} - -
- {row.product.retailer.name} + +
+ + {row.product.retailer.name} + + + {row.product.brand} +
@@ -315,6 +449,14 @@ defmodule AmmopricesWeb.CaliberLive.Show do {"$#{:erlang.float_to_binary(row.price_cents / 100, decimals: 2)}"} + + + {row.product.grain_weight}gr + + + — + + {row.product.round_count || "—"} @@ -415,8 +557,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do callbacks: { label: function(ctx) { const val = ctx.raw - if (val < 100) return ctx.dataset.label + ": " + val + "¢/rd" - return ctx.dataset.label + ": $" + (val / 100).toFixed(2) + "/rd" + return ctx.dataset.label + ": $" + (val / 100).toFixed(2) } } } @@ -433,7 +574,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do y: { ticks: { callback: function(val) { - if (val < 100) return val + "¢" return "$" + (val / 100).toFixed(2) }, font: { size: 10 } diff --git a/priv/repo/migrations/20260311211428_add_subsonic_and_filter_indexes.exs b/priv/repo/migrations/20260311211428_add_subsonic_and_filter_indexes.exs new file mode 100644 index 0000000..621f6a9 --- /dev/null +++ b/priv/repo/migrations/20260311211428_add_subsonic_and_filter_indexes.exs @@ -0,0 +1,13 @@ +defmodule Ammoprices.Repo.Migrations.AddSubsonicAndFilterIndexes do + use Ecto.Migration + + def change do + alter table(:products) do + add :subsonic, :boolean, default: false, null: false + end + + create index(:products, [:caliber_id, :casing]) + create index(:products, [:caliber_id, :grain_weight]) + create index(:products, [:caliber_id, :subsonic]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index f5b65a8..d0f93c3 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -13,6 +13,11 @@ retailers = [ name: "SGAmmo", slug: "sgammo", base_url: "https://www.sgammo.com" + }, + %{ + name: "Target Sports USA", + slug: "target-sports-usa", + base_url: "https://www.targetsportsusa.com" } ] diff --git a/test/ammoprices/catalog_test.exs b/test/ammoprices/catalog_test.exs index 0578043..da65a70 100644 --- a/test/ammoprices/catalog_test.exs +++ b/test/ammoprices/catalog_test.exs @@ -141,6 +141,51 @@ defmodule Ammoprices.CatalogTest do assert product2.in_stock == false end + test "upsert_product/3 stores subsonic flag" do + retailer = retailer_fixture() + caliber = caliber_fixture() + + attrs = %{ + title: "Federal 9mm 147gr Subsonic HP", + url: "/products/federal-subsonic", + brand: "Federal", + grain_weight: 147, + round_count: 50, + casing: "brass", + condition: "new", + in_stock: true, + subsonic: true, + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, product} = Catalog.upsert_product(retailer.id, caliber.id, attrs) + assert product.subsonic == true + end + + test "upsert_product/3 updates subsonic on conflict" do + retailer = retailer_fixture() + caliber = caliber_fixture() + + attrs = %{ + title: "Federal 9mm 147gr HP", + url: "/products/federal-hp", + brand: "Federal", + grain_weight: 147, + round_count: 50, + casing: "brass", + in_stock: true, + subsonic: false, + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + assert {:ok, _} = Catalog.upsert_product(retailer.id, caliber.id, attrs) + + assert {:ok, updated} = + Catalog.upsert_product(retailer.id, caliber.id, %{attrs | subsonic: true}) + + assert updated.subsonic == true + end + test "list_products_for_caliber/2 returns products for a caliber" do caliber = caliber_fixture() retailer = retailer_fixture() @@ -152,4 +197,56 @@ defmodule Ammoprices.CatalogTest do assert hd(results).id == product.id end end + + describe "distinct_grain_weights_for_caliber/1" do + test "returns sorted unique grain weights" do + caliber = caliber_fixture() + retailer = retailer_fixture() + + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147}) + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115}) + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115}) + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 124}) + + result = Catalog.distinct_grain_weights_for_caliber(caliber.id) + assert result == [115, 124, 147] + end + + test "excludes nil grain weights" do + caliber = caliber_fixture() + retailer = retailer_fixture() + + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115}) + product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: nil}) + + result = Catalog.distinct_grain_weights_for_caliber(caliber.id) + assert result == [115] + end + end + + describe "distinct_casings_for_caliber/1" do + test "returns sorted unique casings" do + caliber = caliber_fixture() + retailer = retailer_fixture() + + product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"}) + product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel"}) + product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"}) + product_fixture(%{caliber: caliber, retailer: retailer, casing: "aluminum"}) + + result = Catalog.distinct_casings_for_caliber(caliber.id) + assert result == ["aluminum", "brass", "steel"] + end + + test "excludes nil casings" do + caliber = caliber_fixture() + retailer = retailer_fixture() + + product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"}) + product_fixture(%{caliber: caliber, retailer: retailer, casing: nil}) + + result = Catalog.distinct_casings_for_caliber(caliber.id) + assert result == ["brass"] + end + end end diff --git a/test/ammoprices/prices_test.exs b/test/ammoprices/prices_test.exs index 3337551..e187db8 100644 --- a/test/ammoprices/prices_test.exs +++ b/test/ammoprices/prices_test.exs @@ -73,6 +73,101 @@ defmodule Ammoprices.PricesTest do assert hd(results).in_stock == true end + test "filters by casing" do + caliber = caliber_fixture() + retailer = retailer_fixture() + now = DateTime.truncate(DateTime.utc_now(), :second) + + brass = product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"}) + steel = product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel"}) + + snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now}) + + results = Prices.latest_prices_for_caliber(caliber.id, casing: "steel") + assert length(results) == 1 + assert hd(results).price_per_round_cents == 20 + end + + test "filters by grain_weight" do + caliber = caliber_fixture() + retailer = retailer_fixture() + now = DateTime.truncate(DateTime.utc_now(), :second) + + p115 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115}) + p147 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147}) + + snapshot_fixture(%{product: p115, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: p147, price_per_round_cents: 40, in_stock: true, recorded_at: now}) + + results = Prices.latest_prices_for_caliber(caliber.id, grain_weight: 147) + assert length(results) == 1 + assert hd(results).price_per_round_cents == 40 + end + + test "filters by subsonic" do + caliber = caliber_fixture() + retailer = retailer_fixture() + now = DateTime.truncate(DateTime.utc_now(), :second) + + regular = product_fixture(%{caliber: caliber, retailer: retailer, subsonic: false}) + sub = product_fixture(%{caliber: caliber, retailer: retailer, subsonic: true}) + + snapshot_fixture(%{product: regular, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: sub, price_per_round_cents: 50, in_stock: true, recorded_at: now}) + + results = Prices.latest_prices_for_caliber(caliber.id, subsonic: true) + assert length(results) == 1 + assert hd(results).price_per_round_cents == 50 + end + + test "combines multiple filters" do + caliber = caliber_fixture() + retailer = retailer_fixture() + now = DateTime.truncate(DateTime.utc_now(), :second) + + match = + product_fixture(%{ + caliber: caliber, + retailer: retailer, + casing: "brass", + grain_weight: 147, + subsonic: true + }) + + no_match1 = + product_fixture(%{ + caliber: caliber, + retailer: retailer, + casing: "steel", + grain_weight: 147, + subsonic: true + }) + + no_match2 = + product_fixture(%{ + caliber: caliber, + retailer: retailer, + casing: "brass", + grain_weight: 115, + subsonic: false + }) + + snapshot_fixture(%{product: match, price_per_round_cents: 50, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: no_match1, price_per_round_cents: 30, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: no_match2, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + + results = + Prices.latest_prices_for_caliber(caliber.id, + casing: "brass", + grain_weight: 147, + subsonic: true + ) + + assert length(results) == 1 + assert hd(results).price_per_round_cents == 50 + end + test "respects limit option" do caliber = caliber_fixture() retailer = retailer_fixture() diff --git a/test/ammoprices/scraping/retailers/lucky_gunner_test.exs b/test/ammoprices/scraping/retailers/lucky_gunner_test.exs index 265105b..5568c72 100644 --- a/test/ammoprices/scraping/retailers/lucky_gunner_test.exs +++ b/test/ammoprices/scraping/retailers/lucky_gunner_test.exs @@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do test "extracts all products from HTML", %{html: html} do products = LuckyGunner.parse_products(html) - assert length(products) == 3 + assert length(products) == 4 end test "extracts product title", %{html: html} do @@ -88,5 +88,22 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do [first | _] = LuckyGunner.parse_products(html) assert first.in_stock == true end + + test "detects subsonic from title", %{html: html} do + products = LuckyGunner.parse_products(html) + # 4th product has "Subsonic" in title + assert Enum.at(products, 3).subsonic == true + end + + test "non-subsonic products return false", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.subsonic == false + end + + test "detects casing from description with TextDetector fallback", %{html: html} do + products = LuckyGunner.parse_products(html) + # 4th product: nickel-plated brass from description + assert Enum.at(products, 3).casing == "brass" + end end end diff --git a/test/ammoprices/scraping/retailers/sg_ammo_test.exs b/test/ammoprices/scraping/retailers/sg_ammo_test.exs index d5bda7d..059d26a 100644 --- a/test/ammoprices/scraping/retailers/sg_ammo_test.exs +++ b/test/ammoprices/scraping/retailers/sg_ammo_test.exs @@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do test "extracts all products from HTML", %{html: html} do products = SgAmmo.parse_products(html) - assert length(products) == 3 + assert length(products) == 4 end test "extracts product title", %{html: html} do @@ -73,5 +73,22 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do [first | _] = SgAmmo.parse_products(html) assert first.in_stock == true end + + test "detects casing from title", %{html: html} do + products = SgAmmo.parse_products(html) + # 4th product has "Steel Case" in title + assert Enum.at(products, 3).casing == "steel" + end + + test "detects subsonic from title", %{html: html} do + products = SgAmmo.parse_products(html) + # 4th product has "Subsonic" in title + assert Enum.at(products, 3).subsonic == true + end + + test "non-subsonic products return false", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.subsonic == false + end end end diff --git a/test/ammoprices/scraping/retailers/target_sports_usa_test.exs b/test/ammoprices/scraping/retailers/target_sports_usa_test.exs new file mode 100644 index 0000000..92b04e6 --- /dev/null +++ b/test/ammoprices/scraping/retailers/target_sports_usa_test.exs @@ -0,0 +1,118 @@ +defmodule Ammoprices.Scraping.Retailers.TargetSportsUsaTest do + use ExUnit.Case, async: true + + alias Ammoprices.Scraping.Retailers.TargetSportsUsa + + @fixture_path "test/fixtures/target_sports_usa/9mm.html" + + describe "retailer_slug/0" do + test "returns target-sports-usa" do + assert TargetSportsUsa.retailer_slug() == "target-sports-usa" + end + end + + describe "category_url/1" do + test "maps 9mm-luger caliber to correct URL" do + caliber = %{slug: "9mm-luger", category: "handgun"} + assert TargetSportsUsa.category_url(caliber) == "/9mm-luger-ammo-c-51.aspx" + end + + test "maps 300-blackout caliber to correct URL" do + caliber = %{slug: "300-blackout", category: "rifle"} + assert TargetSportsUsa.category_url(caliber) == "/300-aac-blackout-ammo-c-969.aspx" + end + + test "returns nil for unmapped caliber" do + caliber = %{slug: "unknown", category: "handgun"} + assert TargetSportsUsa.category_url(caliber) == nil + end + end + + describe "parse_products/1" do + setup do + html = File.read!(@fixture_path) + %{html: html} + end + + test "extracts all products from HTML", %{html: html} do + products = TargetSportsUsa.parse_products(html) + assert length(products) == 4 + end + + test "extracts product title", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.title =~ "Federal Champion 9mm Ammo 115 Grain Full Metal Jacket" + end + + test "extracts product URL", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.url == "/federal-champion-9mm-ammo-115-grain-fmj-wm5199-p-58432.aspx" + end + + test "extracts price in cents", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.price_cents == 1275 + end + + test "extracts price per round in cents", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.price_per_round_cents == 26 + end + + test "extracts brand from h2 strong", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.brand == "Federal" + end + + test "strips trailing Ammunition/Ammo from brand", %{html: html} do + products = TargetSportsUsa.parse_products(html) + # "Winchester Ammo" -> "Winchester" + assert Enum.at(products, 1).brand == "Winchester" + # "Tula Ammo" -> "Tula" + assert Enum.at(products, 3).brand == "Tula" + end + + test "extracts grain weight from title", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.grain_weight == 115 + end + + test "extracts round count from title", %{html: html} do + products = TargetSportsUsa.parse_products(html) + # Second product: "50 Round Box" + assert Enum.at(products, 1).round_count == 50 + end + + test "marks in-stock products", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.in_stock == true + end + + test "marks out-of-stock products", %{html: html} do + products = TargetSportsUsa.parse_products(html) + assert Enum.at(products, 3).in_stock == false + end + + test "detects subsonic from title", %{html: html} do + products = TargetSportsUsa.parse_products(html) + # 3rd product has "Subsonic" in title + assert Enum.at(products, 2).subsonic == true + end + + test "non-subsonic products return false", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.subsonic == false + end + + test "detects casing from title", %{html: html} do + products = TargetSportsUsa.parse_products(html) + # 4th product has "Steel Case" in title + assert Enum.at(products, 3).casing == "steel" + end + + test "returns nil casing when not detected", %{html: html} do + [first | _] = TargetSportsUsa.parse_products(html) + assert first.casing == nil + end + end +end diff --git a/test/ammoprices/scraping/runner_test.exs b/test/ammoprices/scraping/runner_test.exs index 7e882be..dcaec8e 100644 --- a/test/ammoprices/scraping/runner_test.exs +++ b/test/ammoprices/scraping/runner_test.exs @@ -25,11 +25,11 @@ defmodule Ammoprices.Scraping.RunnerTest do scraper = LuckyGunner assert {:ok, stats} = Runner.run(scraper, caliber) - assert stats.products_count == 3 - assert stats.snapshots_count == 3 + assert stats.products_count == 4 + assert stats.snapshots_count == 4 products = Catalog.list_products_for_caliber(caliber.id) - assert length(products) == 3 + assert length(products) == 4 end test "updates retailer last_scraped_at", %{caliber: caliber} do diff --git a/test/ammoprices/scraping/scrape_job_test.exs b/test/ammoprices/scraping/scrape_job_test.exs index c32a2e4..1a93eb0 100644 --- a/test/ammoprices/scraping/scrape_job_test.exs +++ b/test/ammoprices/scraping/scrape_job_test.exs @@ -11,6 +11,13 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do setup do retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"}) retailer_fixture(%{name: "SGAmmo", slug: "sgammo", base_url: "https://www.sgammo.com"}) + + retailer_fixture(%{ + name: "Target Sports USA", + slug: "target-sports-usa", + base_url: "https://www.targetsportsusa.com" + }) + caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]}) Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn -> diff --git a/test/ammoprices/scraping/text_detector_test.exs b/test/ammoprices/scraping/text_detector_test.exs new file mode 100644 index 0000000..68e1032 --- /dev/null +++ b/test/ammoprices/scraping/text_detector_test.exs @@ -0,0 +1,69 @@ +defmodule Ammoprices.Scraping.TextDetectorTest do + use ExUnit.Case, async: true + + alias Ammoprices.Scraping.TextDetector + + describe "detect_subsonic/1" do + test "detects 'subsonic' in title" do + assert TextDetector.detect_subsonic("Federal 9mm 147gr Subsonic HP") == true + end + + test "detects 'sub-sonic' in title" do + assert TextDetector.detect_subsonic("Federal 9mm Sub-Sonic 147gr") == true + end + + test "detects 'sub sonic' in title" do + assert TextDetector.detect_subsonic("Federal 9mm Sub Sonic 147gr") == true + end + + test "is case-insensitive" do + assert TextDetector.detect_subsonic("Federal 9mm SUBSONIC 147gr") == true + end + + test "returns false for non-subsonic title" do + assert TextDetector.detect_subsonic("Federal 9mm 115gr FMJ") == false + end + + test "returns false for nil" do + assert TextDetector.detect_subsonic(nil) == false + end + end + + describe "detect_casing/1" do + test "detects steel case" do + assert TextDetector.detect_casing("Wolf 9mm 115gr FMJ Steel Case") == "steel" + end + + test "detects steel cased" do + assert TextDetector.detect_casing("Wolf 9mm 115gr FMJ Steel Cased") == "steel" + end + + test "detects brass case" do + assert TextDetector.detect_casing("Blazer Brass 9mm 115gr FMJ") == "brass" + end + + test "detects brass cased" do + assert TextDetector.detect_casing("Federal 9mm Brass Cased 115gr") == "brass" + end + + test "detects aluminum case" do + assert TextDetector.detect_casing("CCI Aluminum Case 9mm 115gr") == "aluminum" + end + + test "detects nickel plated as brass" do + assert TextDetector.detect_casing("Speer 9mm Nickel Plated 124gr") == "brass" + end + + test "returns nil for no match" do + assert TextDetector.detect_casing("Federal 9mm 115gr FMJ") == nil + end + + test "returns nil for nil input" do + assert TextDetector.detect_casing(nil) == nil + end + + test "is case-insensitive" do + assert TextDetector.detect_casing("Wolf 9mm STEEL CASE 115gr") == "steel" + end + end +end diff --git a/test/ammoprices_web/live/caliber_live/show_test.exs b/test/ammoprices_web/live/caliber_live/show_test.exs index e0892f3..8cb3417 100644 --- a/test/ammoprices_web/live/caliber_live/show_test.exs +++ b/test/ammoprices_web/live/caliber_live/show_test.exs @@ -89,5 +89,104 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do assert view |> element("#range-7d") |> render_click() assert has_element?(view, "#price-chart-container") end + + test "renders casing filter buttons", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + brass = product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass", in_stock: true}) + steel = product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel", in_stock: true}) + snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#filter-casing-brass") + assert has_element?(view, "#filter-casing-steel") + end + + test "renders grain weight filter buttons", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + p115 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115, in_stock: true}) + p147 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147, in_stock: true}) + snapshot_fixture(%{product: p115, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: p147, price_per_round_cents: 40, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#filter-grain-115") + assert has_element?(view, "#filter-grain-147") + end + + test "renders subsonic filter toggle", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true}) + snapshot_fixture(%{product: product, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#filter-subsonic") + end + + test "displays grain weight and brand in product table", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + product = + product_fixture(%{ + caliber: caliber, + retailer: retailer, + grain_weight: 115, + brand: "Federal", + in_stock: true + }) + + snapshot_fixture(%{product: product, price_per_round_cents: 28, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#products [id]") + end + + test "filters by casing when clicked", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + brass = + product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass", in_stock: true, title: "Brass Ammo"}) + + steel = + product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel", in_stock: true, title: "Steel Ammo"}) + + snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + # Click steel filter + view |> element("#filter-casing-steel") |> render_click() + + # Should still have the filter button active + assert has_element?(view, "#filter-casing-steel") + end + + test "receives real-time price updates via PubSub", %{conn: conn, caliber: caliber, retailer: retailer} do + now = DateTime.truncate(DateTime.utc_now(), :second) + product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true}) + snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + # Add a new product + snapshot + new_product = + product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, title: "New Product", casing: "brass"}) + + snapshot_fixture(%{product: new_product, price_per_round_cents: 18, in_stock: true, recorded_at: now}) + + # Broadcast price update + Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}}) + + # The view should re-render with updated data + html = render(view) + assert html =~ "New Product" + end end end diff --git a/test/fixtures/lucky_gunner/9mm.html b/test/fixtures/lucky_gunner/9mm.html index 17875bb..79439b9 100644 --- a/test/fixtures/lucky_gunner/9mm.html +++ b/test/fixtures/lucky_gunner/9mm.html @@ -100,4 +100,38 @@
+ +
  • + + Federal HST 9mm 147gr JHP Subsonic + +
    +

    + + 9mm - 147 Grain JHP - Federal HST Subsonic - 50 Rounds + +

    +
    +
    +
    + $31.00 +
    +
    +
    +

    62¢ per round

    +

    + 35 In Stock +

    +
    +
    +
    +
      +
    • Quantity - 50 rounds per box
    • +
    • Manufacturer - Federal
    • +
    • Bullets - 147 grain jacketed hollow point (JHP)
    • +
    • Casings - Boxer-primed nickel-plated brass
    • +
    +
    +
    +
  • diff --git a/test/fixtures/sg_ammo/9mm.html b/test/fixtures/sg_ammo/9mm.html index aeb7065..85aca49 100644 --- a/test/fixtures/sg_ammo/9mm.html +++ b/test/fixtures/sg_ammo/9mm.html @@ -72,5 +72,28 @@ ($0.28 Per Round) + + + + + + +

    + + 50 Round Box - 9mm Luger Subsonic 147 Grain FMJ Steel Case Ammo by Tula + +

    +
    SKU: TUL-9SUB
    + + + 80+ + + + + $11.95 Each + + ($0.24 Per Round) + + diff --git a/test/fixtures/target_sports_usa/9mm.html b/test/fixtures/target_sports_usa/9mm.html new file mode 100644 index 0000000..8810b91 --- /dev/null +++ b/test/fixtures/target_sports_usa/9mm.html @@ -0,0 +1,61 @@ + diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index bf3e8c2..c13f5fd 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -63,6 +63,7 @@ defmodule Ammoprices.Fixtures do casing: "brass", condition: "new", in_stock: true, + subsonic: false, last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) }, Map.drop(attrs, [:retailer, :caliber])