ammocpr/test/ammoprices/scraping/runner_test.exs
Graham McIntire 2efb35dc48
Stop scrape chain from dying on a single bad product
A scraped product with nil price_per_round_cents made Prices.create_snapshot
return {:error, changeset}, which blew up the {:ok, _} match in the runner,
crashed the whole perform, and after 3 retries discarded the job. Nothing
reseeds the chain once the job is discarded, so prod scrapes silently died
on Mar 17 and stayed dead until the app was restarted.

- Runner handles snapshot insert errors by logging and continuing instead
  of raising.
- ScrapeJob wraps each Runner.run in try/rescue so one retailer crashing
  doesn't take down the whole cycle.
- ScrapeJob moves schedule_next into an after block so the chain is always
  reseeded, even if the scrape loop crashes.
2026-04-22 15:51:15 -05:00

161 lines
5 KiB
Elixir

defmodule Ammoprices.Scraping.RunnerTest do
use Ammoprices.DataCase, async: true
import Ammoprices.Fixtures
alias Ammoprices.Catalog
alias Ammoprices.Catalog.Product
alias Ammoprices.Scraping.HttpClient
alias Ammoprices.Scraping.Retailers.LuckyGunner
alias Ammoprices.Scraping.Runner
@fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html")
setup do
retailer = retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
Req.Test.stub(HttpClient, fn conn ->
Req.Test.html(conn, @fixture_html)
end)
%{retailer: retailer, caliber: caliber}
end
describe "run/2" do
test "creates products and snapshots from scrape", %{caliber: caliber} do
scraper = LuckyGunner
assert {:ok, stats} = Runner.run(scraper, caliber)
assert stats.products_count == 4
assert stats.snapshots_count == 4
products = Catalog.list_products_for_caliber(caliber.id)
assert length(products) == 4
end
test "updates retailer last_scraped_at", %{caliber: caliber} do
scraper = LuckyGunner
{:ok, _stats} = Runner.run(scraper, caliber)
retailer = Catalog.get_retailer_by_slug!("lucky-gunner")
assert retailer.last_scraped_at
end
test "handles scraper returning nil category_url", %{caliber: _caliber} do
caliber = caliber_fixture(%{name: "Unknown", slug: "unknown", category: "handgun"})
scraper = LuckyGunner
assert {:ok, stats} = Runner.run(scraper, caliber)
assert stats.products_count == 0
end
test "marks products not seen in current scrape as out of stock",
%{retailer: retailer, caliber: caliber} do
# Pre-existing product that won't appear in the fixture scrape
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
stale =
product_fixture(%{
retailer: retailer,
caliber: caliber,
url: "/products/no-longer-listed",
last_seen_at: old,
in_stock: true
})
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
assert Ammoprices.Repo.get!(Product, stale.id).in_stock == false
end
test "does not prune products from other retailers", %{caliber: caliber} do
other_retailer = retailer_fixture(%{slug: "other", base_url: "https://other.com"})
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
other =
product_fixture(%{
retailer: other_retailer,
caliber: caliber,
url: "/products/other-retailer",
last_seen_at: old,
in_stock: true
})
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
assert Ammoprices.Repo.get!(Product, other.id).in_stock == true
end
test "continues processing when a single product's snapshot fails validation",
%{retailer: retailer, caliber: caliber} do
defmodule BrokenScraper do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
def retailer_slug, do: "lucky-gunner"
def category_url(_), do: "/any"
def parse_products(_body) do
[
%{
title: "Good 9mm",
url: "/products/good",
brand: "Good",
grain_weight: 115,
round_count: 50,
casing: "brass",
in_stock: true,
subsonic: false,
price_cents: 1599,
price_per_round_cents: 32
},
%{
title: "Bad 9mm (no round count)",
url: "/products/bad",
brand: "Bad",
grain_weight: 115,
round_count: nil,
casing: "brass",
in_stock: false,
subsonic: false,
price_cents: 2167,
price_per_round_cents: nil
}
]
end
end
assert {:ok, stats} = Runner.run(BrokenScraper, caliber)
assert stats.products_count == 1
assert stats.snapshots_count == 1
good =
Ammoprices.Repo.get_by!(Product, retailer_id: retailer.id, url: "/products/good")
assert good.in_stock == true
end
test "does not prune when scrape returns zero products", %{retailer: retailer, caliber: caliber} do
Req.Test.stub(HttpClient, fn conn ->
Req.Test.html(conn, "<html><body>no products here</body></html>")
end)
old = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
stale =
product_fixture(%{
retailer: retailer,
caliber: caliber,
url: "/products/still-here",
last_seen_at: old,
in_stock: true
})
assert {:ok, _stats} = Runner.run(LuckyGunner, caliber)
assert Ammoprices.Repo.get!(Product, stale.id).in_stock == true
end
end
end