Phoenix 1.8 app that scrapes ammunition retailers (Lucky Gunner, SGAmmo) for price data and displays historical price trends. - Data model: retailers, calibers, products, price snapshots - Scraper infrastructure with Req, Floki, realistic browser headers - Oban-scheduled scrape jobs (every 4h with randomized delays) - LiveView pages: homepage with category cards, caliber detail with price table, Chart.js price history, and price stats banner - 18 seeded calibers across handgun/rifle/rimfire/shotgun categories - 77 tests
92 lines
2.9 KiB
Elixir
92 lines
2.9 KiB
Elixir
defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Ammoprices.Scraping.Retailers.LuckyGunner
|
|
|
|
@fixture_path "test/fixtures/lucky_gunner/9mm.html"
|
|
|
|
describe "retailer_slug/0" do
|
|
test "returns lucky-gunner" do
|
|
assert LuckyGunner.retailer_slug() == "lucky-gunner"
|
|
end
|
|
end
|
|
|
|
describe "category_url/1" do
|
|
test "maps 9mm-luger caliber to correct URL" do
|
|
caliber = %{slug: "9mm-luger", category: "handgun"}
|
|
assert LuckyGunner.category_url(caliber) == "/handgun/9mm-ammo"
|
|
end
|
|
|
|
test "returns nil for unmapped caliber" do
|
|
caliber = %{slug: "unknown-caliber", category: "handgun"}
|
|
assert LuckyGunner.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 = LuckyGunner.parse_products(html)
|
|
assert length(products) == 3
|
|
end
|
|
|
|
test "extracts product title", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.title == "9mm - 115 Grain FMJ - Wolf - 1350 Rounds **STEEL CASES**"
|
|
end
|
|
|
|
test "extracts product URL", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.url == "https://www.luckygunner.com/9mm-115-grain-fmj-wolf-1350-rounds"
|
|
end
|
|
|
|
test "extracts price in cents", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.price_cents == 27_000
|
|
end
|
|
|
|
test "extracts price per round in cents", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.price_per_round_cents == 20
|
|
end
|
|
|
|
test "extracts fractional price per round", %{html: html} do
|
|
products = LuckyGunner.parse_products(html)
|
|
second = Enum.at(products, 1)
|
|
# 21.2¢ → 21 cents (truncated to integer)
|
|
assert second.price_per_round_cents == 21
|
|
end
|
|
|
|
test "extracts brand from description", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.brand == "Wolf"
|
|
end
|
|
|
|
test "extracts grain weight from description", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.grain_weight == 115
|
|
end
|
|
|
|
test "extracts round count from title", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.round_count == 1350
|
|
end
|
|
|
|
test "detects casing material from description", %{html: html} do
|
|
products = LuckyGunner.parse_products(html)
|
|
# First product: steel casings
|
|
assert hd(products).casing == "steel"
|
|
# Third product: brass casings
|
|
assert Enum.at(products, 2).casing == "brass"
|
|
end
|
|
|
|
test "marks products as in stock", %{html: html} do
|
|
[first | _] = LuckyGunner.parse_products(html)
|
|
assert first.in_stock == true
|
|
end
|
|
end
|
|
end
|