ammocpr/test/ammoprices/prices_test.exs
Graham McIntire bbe5bde145
Initial implementation of ammo price tracker
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
2026-03-11 15:58:12 -05:00

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