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
230 lines
8.1 KiB
Elixir
230 lines
8.1 KiB
Elixir
defmodule Ammoprices.PricesTest do
|
|
use Ammoprices.DataCase, async: true
|
|
|
|
import Ammoprices.Fixtures
|
|
|
|
alias Ammoprices.Prices
|
|
|
|
describe "create_snapshot/2" do
|
|
test "creates a price snapshot for a product" do
|
|
product = product_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
attrs = %{
|
|
price_cents: 1599,
|
|
price_per_round_cents: 32,
|
|
in_stock: true,
|
|
recorded_at: now
|
|
}
|
|
|
|
assert {:ok, snapshot} = Prices.create_snapshot(product.id, attrs)
|
|
assert snapshot.product_id == product.id
|
|
assert snapshot.price_cents == 1599
|
|
assert snapshot.price_per_round_cents == 32
|
|
assert snapshot.recorded_at == now
|
|
end
|
|
|
|
test "returns error with invalid data" do
|
|
product = product_fixture()
|
|
assert {:error, changeset} = Prices.create_snapshot(product.id, %{})
|
|
assert %{price_cents: ["can't be blank"]} = errors_on(changeset)
|
|
end
|
|
end
|
|
|
|
describe "latest_prices_for_caliber/2" do
|
|
test "returns most recent snapshot per product sorted by price_per_round" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
|
|
cheap_product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Cheap"})
|
|
expensive_product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Expensive"})
|
|
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
old = DateTime.add(now, -3600, :second)
|
|
|
|
# Old snapshot for cheap product (higher price)
|
|
snapshot_fixture(%{product: cheap_product, price_per_round_cents: 50, recorded_at: old})
|
|
# Latest snapshot for cheap product (lower price)
|
|
snapshot_fixture(%{product: cheap_product, price_per_round_cents: 25, recorded_at: now})
|
|
# Latest snapshot for expensive product
|
|
snapshot_fixture(%{product: expensive_product, price_per_round_cents: 40, recorded_at: now})
|
|
|
|
results = Prices.latest_prices_for_caliber(caliber.id)
|
|
|
|
assert length(results) == 2
|
|
# Sorted by price_per_round_cents ASC
|
|
assert hd(results).price_per_round_cents == 25
|
|
assert List.last(results).price_per_round_cents == 40
|
|
end
|
|
|
|
test "filters by in_stock" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
in_stock_product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
oos_product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: false})
|
|
|
|
snapshot_fixture(%{product: in_stock_product, in_stock: true, recorded_at: now})
|
|
snapshot_fixture(%{product: oos_product, in_stock: false, recorded_at: now})
|
|
|
|
results = Prices.latest_prices_for_caliber(caliber.id, in_stock: true)
|
|
assert length(results) == 1
|
|
assert hd(results).in_stock == true
|
|
end
|
|
|
|
test "respects limit option" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
for i <- 1..5 do
|
|
p = product_fixture(%{caliber: caliber, retailer: retailer})
|
|
snapshot_fixture(%{product: p, price_per_round_cents: i * 10, recorded_at: now})
|
|
end
|
|
|
|
results = Prices.latest_prices_for_caliber(caliber.id, limit: 3)
|
|
assert length(results) == 3
|
|
end
|
|
end
|
|
|
|
describe "daily_averages_for_caliber/2" do
|
|
test "returns daily avg/min/max of price_per_round_cents" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer})
|
|
|
|
today = DateTime.truncate(DateTime.utc_now(), :second)
|
|
yesterday = DateTime.add(today, -86_400, :second)
|
|
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 30, recorded_at: today})
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 40, recorded_at: today})
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 20, recorded_at: yesterday})
|
|
|
|
results = Prices.daily_averages_for_caliber(caliber.id, days: 7)
|
|
|
|
assert length(results) == 2
|
|
# Results ordered by date ASC
|
|
[day1, day2] = results
|
|
assert day1.min_ppr == 20
|
|
assert day1.max_ppr == 20
|
|
assert day2.min_ppr == 30
|
|
assert day2.max_ppr == 40
|
|
assert day2.avg_ppr == 35
|
|
end
|
|
end
|
|
|
|
describe "price_history_for_product/2" do
|
|
test "returns all snapshots for a product in date range" do
|
|
product = product_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
for i <- 0..4 do
|
|
snapshot_fixture(%{
|
|
product: product,
|
|
price_per_round_cents: 30 + i,
|
|
recorded_at: DateTime.add(now, -i * 86_400, :second)
|
|
})
|
|
end
|
|
|
|
results = Prices.price_history_for_product(product.id, days: 2)
|
|
# Should include today, yesterday, and day-before (boundary) = 3 of 5 snapshots
|
|
assert length(results) == 3
|
|
end
|
|
end
|
|
|
|
describe "price_stats_for_caliber/1" do
|
|
test "returns min, max, avg stats for a caliber" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
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: 20, in_stock: true, recorded_at: now})
|
|
|
|
snapshot_fixture(%{
|
|
product: product,
|
|
price_per_round_cents: 40,
|
|
in_stock: true,
|
|
recorded_at: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
snapshot_fixture(%{
|
|
product: product,
|
|
price_per_round_cents: 30,
|
|
in_stock: true,
|
|
recorded_at: DateTime.add(now, -7200, :second)
|
|
})
|
|
|
|
stats = Prices.price_stats_for_caliber(caliber.id)
|
|
|
|
assert stats.current_min == 20
|
|
assert stats.all_time_low == 20
|
|
assert stats.all_time_high == 40
|
|
assert is_integer(stats.thirty_day_avg)
|
|
end
|
|
|
|
test "returns thirty_day_avg based on recent snapshots" do
|
|
caliber = caliber_fixture()
|
|
retailer = retailer_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
|
|
|
|
# Recent snapshots (within 30 days)
|
|
snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now})
|
|
|
|
snapshot_fixture(%{
|
|
product: product,
|
|
price_per_round_cents: 40,
|
|
in_stock: true,
|
|
recorded_at: DateTime.add(now, -86_400, :second)
|
|
})
|
|
|
|
# Old snapshot (outside 30 days)
|
|
snapshot_fixture(%{
|
|
product: product,
|
|
price_per_round_cents: 100,
|
|
in_stock: true,
|
|
recorded_at: DateTime.add(now, -40 * 86_400, :second)
|
|
})
|
|
|
|
stats = Prices.price_stats_for_caliber(caliber.id)
|
|
|
|
# 30-day avg should be (30+40)/2 = 35, not include the 100
|
|
assert stats.thirty_day_avg == 35
|
|
end
|
|
|
|
test "returns nil stats when no snapshots exist" do
|
|
caliber = caliber_fixture()
|
|
stats = Prices.price_stats_for_caliber(caliber.id)
|
|
|
|
assert stats.current_min == nil
|
|
assert stats.all_time_low == nil
|
|
assert stats.all_time_high == nil
|
|
assert stats.thirty_day_avg == nil
|
|
end
|
|
end
|
|
|
|
describe "cheapest_per_caliber/0" do
|
|
test "returns min price_per_round for each caliber" do
|
|
caliber1 = caliber_fixture(%{name: "9mm", slug: "9mm"})
|
|
caliber2 = caliber_fixture(%{name: "5.56", slug: "556"})
|
|
retailer = retailer_fixture()
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
p1 = product_fixture(%{caliber: caliber1, retailer: retailer, in_stock: true})
|
|
p2 = product_fixture(%{caliber: caliber2, retailer: retailer, in_stock: true})
|
|
|
|
snapshot_fixture(%{product: p1, price_per_round_cents: 25, in_stock: true, recorded_at: now})
|
|
snapshot_fixture(%{product: p1, price_per_round_cents: 30, in_stock: true, recorded_at: now})
|
|
snapshot_fixture(%{product: p2, price_per_round_cents: 35, in_stock: true, recorded_at: now})
|
|
|
|
results = Prices.cheapest_per_caliber()
|
|
result_map = Map.new(results, fn r -> {r.caliber_id, r.min_ppr} end)
|
|
|
|
assert result_map[caliber1.id] == 25
|
|
assert result_map[caliber2.id] == 35
|
|
end
|
|
end
|
|
end
|