- Fix Oban self-scheduling chain silently dying (remove unique constraint so after block can schedule next job)
- Fix Natchez and TrueShotAmmo extract_price defaulting to 0 for missing price data
- Fix caliber_matcher redundant String.downcase inside Enum.find (pre-downcase outside loop)
- Fix runner.ex double iteration (Enum.map + Enum.count merged into single Enum.reduce)
- Fix price_stats_for_caliber firing 3 queries instead of 2 (merge all_time + thirty_day via FILTER)
- Fix PSA duplicate Floki.find on same selector (merge extract_title + extract_url)
- Fix PubSub broadcast carrying no caliber IDs (now includes IDs; clients skip irrelevant reloads)
- Fix BulkAmmo ^ anchor skipping non-leading round counts
- Fix TargetSportsUSA fragile exact text match 'Add To Cart' (use case-insensitive contains)
- Fix SgAmmo dead destructure {_, _, _} = row
- Fix text_detector brass/nickel detection order
- Fix Natchez GraphQL string interpolation (add escape_gql_string)
- Fix chart data triple Enum.map merged into single reduce
- Change Oban scraping queue workers from 2 to 1 (only one needed with Process.sleep)
238 lines
10 KiB
Elixir
238 lines
10 KiB
Elixir
defmodule AmmopricesWeb.CaliberLive.ShowTest do
|
|
use AmmopricesWeb.ConnCase
|
|
|
|
import Ammoprices.Fixtures
|
|
import Phoenix.LiveViewTest
|
|
|
|
setup do
|
|
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"})
|
|
retailer = retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
|
|
|
|
%{caliber: caliber, retailer: retailer}
|
|
end
|
|
|
|
describe "CaliberLive.Show" do
|
|
test "renders caliber page with title", %{conn: conn, caliber: caliber} do
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
assert has_element?(view, "#caliber-heading")
|
|
end
|
|
|
|
test "renders product listings with stream", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Federal 9mm 115gr"})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
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")
|
|
assert has_element?(view, "#products [id]")
|
|
end
|
|
|
|
test "shows empty state when no products", %{conn: conn, caliber: caliber} do
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
assert has_element?(view, "#products-empty")
|
|
end
|
|
|
|
test "filters by in-stock", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
in_stock = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, title: "In Stock Product"})
|
|
oos = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: false, title: "Out of Stock Product"})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
snapshot_fixture(%{product: in_stock, price_per_round_cents: 28, in_stock: true, recorded_at: now})
|
|
snapshot_fixture(%{product: oos, price_per_round_cents: 22, in_stock: false, recorded_at: now})
|
|
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
# Default is in_stock: true
|
|
assert has_element?(view, "#filter-in-stock")
|
|
end
|
|
|
|
test "renders price stats banner", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
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, "#price-stats")
|
|
end
|
|
|
|
test "renders chart container with data attribute", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now})
|
|
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
assert has_element?(view, "#price-chart-container")
|
|
assert has_element?(view, "[data-chart-data]")
|
|
end
|
|
|
|
test "renders time range buttons", %{conn: conn, caliber: caliber} do
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
assert has_element?(view, "#range-7d")
|
|
assert has_element?(view, "#range-30d")
|
|
assert has_element?(view, "#range-90d")
|
|
assert has_element?(view, "#range-1y")
|
|
assert has_element?(view, "#range-all")
|
|
end
|
|
|
|
test "changes chart range when range button clicked", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now})
|
|
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
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 when subsonic products exist", %{
|
|
conn: conn,
|
|
caliber: caliber,
|
|
retailer: retailer
|
|
} do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, subsonic: 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 "hides subsonic filter when no subsonic products", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, subsonic: false})
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 25, in_stock: true, recorded_at: now})
|
|
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
refute 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 "shows empty chart state when no data exists in range", %{conn: conn, caliber: caliber} do
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
assert has_element?(view, "#price-chart-empty")
|
|
end
|
|
|
|
test "hides empty chart state when data exists in range", %{conn: conn, caliber: caliber, retailer: retailer} do
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 28, in_stock: true, recorded_at: now})
|
|
|
|
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
|
|
|
|
refute has_element?(view, "#price-chart-empty")
|
|
end
|
|
|
|
test "chart container uses phx-update=ignore to survive DOM patches", %{
|
|
conn: conn,
|
|
caliber: caliber,
|
|
retailer: retailer
|
|
} do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115, 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}")
|
|
|
|
# Chart container must have phx-update="ignore" so LiveView
|
|
# does not replace the canvas element managed by Chart.js
|
|
assert has_element?(view, "#price-chart-container[phx-update=ignore]")
|
|
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, [caliber.id]})
|
|
|
|
# The view should re-render with updated data
|
|
html = render(view)
|
|
assert html =~ "New Product"
|
|
end
|
|
end
|
|
end
|