Compare commits

...

10 commits

Author SHA1 Message Date
c56bc12e88
chore: update dependencies 2026-07-08 14:30:50 -05:00
872c94bea1
Fix bugs and performance: Oban chain, 0-cent prices, duplicate queries, regex fragility
- 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)
2026-06-21 18:14:49 -05:00
a52a73ee09
Update dependencies, add Plausible analytics, fix Oban unique states warning 2026-06-06 12:43:19 -05:00
66b39d8ad3
Update dependencies
Includes Oban 2.22 upgrade which requires migration to v14 (adds suspended
state) and bumps decimal to 3.x.
2026-05-11 10:12:53 -05:00
53b04b9090
chore: pin erlang 28.5 and elixir 1.19.5-otp-28 2026-05-05 16:38:15 -05:00
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
ae7e117b23
Prune listings that disappear from retailer scrapes
When a product no longer appears in a successful scrape for its
retailer+caliber, mark it out of stock. After each scrape cycle, hard
delete any product whose last_seen_at is older than 30 days (snapshots
cascade via FK). A zero-result scrape leaves existing listings alone so
a parse failure or transient site glitch can't nuke the catalog.
2026-04-22 15:41:40 -05:00
200b6dd9cf
Update dependencies
- bandit 1.10.3 -> 1.10.4
- credo 1.7.17 -> 1.7.18
- floki 0.38.0 -> 0.38.1
- lazy_html 0.1.10 -> 0.1.11
- oban 2.20.3 -> 2.21.1
- phoenix_live_view 1.1.27 -> 1.1.28
- swoosh 1.23.0 -> 1.25.0
- Chart.js 4.4.7 -> 4.5.1 (vendored)
2026-04-22 15:28:07 -05:00
525692fe05
Add chart empty state and home-page real-time price refresh
- Show "No price history available for this range yet." overlay when
  the caliber chart range contains no daily averages (previously the
  chart rendered as an unlabeled empty grid).
- Subscribe HomeLive to the "prices:updated" PubSub topic so the
  cheapest-per-caliber grid refreshes without a full page reload.
- Drop the unused textColor computation and unused max series from the
  chart hook/data.
2026-04-22 15:28:02 -05:00
f075ab55fb
Fix price chart destroyed by LiveView DOM patches on filter change
LiveView's morphdom was replacing the canvas element inside the chart
container whenever filters were toggled, destroying the Chart.js
rendering context. Add phx-update="ignore" to the chart container so
LiveView preserves JS-managed children after initial render.
2026-03-13 13:38:45 -05:00
25 changed files with 593 additions and 181 deletions

View file

@ -1,3 +1,3 @@
erlang 28.3
erlang 28.5
elixir 1.19.5-otp-28
nodejs 22.17.1

File diff suppressed because one or more lines are too long

View file

@ -30,7 +30,7 @@ config :ammoprices, AmmopricesWeb.Endpoint,
# Configure Oban
config :ammoprices, Oban,
repo: Ammoprices.Repo,
queues: [scraping: 2],
queues: [scraping: 1],
plugins: [Oban.Plugins.Lifeline]
config :ammoprices,

View file

@ -84,6 +84,34 @@ defmodule Ammoprices.Catalog do
)
end
@doc """
Marks products belonging to `retailer_id`/`caliber_id` as out of stock when
their `last_seen_at` is strictly before `cutoff`. Returns `{count, nil}`.
Used by the scrape runner to flag listings that disappeared from the
retailer's current category response.
"""
def mark_unseen_products_out_of_stock(retailer_id, caliber_id, %DateTime{} = cutoff) do
now = DateTime.truncate(DateTime.utc_now(), :second)
Product
|> where([p], p.retailer_id == ^retailer_id)
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], p.last_seen_at < ^cutoff)
|> where([p], p.in_stock == true)
|> Repo.update_all(set: [in_stock: false, updated_at: now])
end
@doc """
Hard-deletes products whose `last_seen_at` is older than `cutoff`. Related
price snapshots cascade via the foreign key. Returns `{count, nil}`.
"""
def delete_stale_products(%DateTime{} = cutoff) do
Product
|> where([p], p.last_seen_at < ^cutoff)
|> Repo.delete_all()
end
def list_products_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)

View file

@ -119,13 +119,25 @@ defmodule Ammoprices.Prices do
Based on in-stock snapshots only.
"""
def price_stats_for_caliber(caliber_id) do
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
stats =
Repo.one(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
select: %{all_time_low: min(ps.price_per_round_cents), all_time_high: max(ps.price_per_round_cents)}
select: %{
all_time_low: min(ps.price_per_round_cents),
all_time_high: max(ps.price_per_round_cents),
thirty_day_avg:
fragment(
"round(avg(?) filter (where ? >= ?))::integer",
ps.price_per_round_cents,
ps.recorded_at,
^thirty_days_ago
)
}
)
)
@ -142,24 +154,11 @@ defmodule Ammoprices.Prices do
|> select([s], min(s.price_per_round_cents))
|> Repo.one()
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
thirty_day_avg =
Repo.one(
from(ps in PriceSnapshot,
join: p in assoc(ps, :product),
where: p.caliber_id == ^caliber_id,
where: ps.in_stock == true,
where: ps.recorded_at >= ^thirty_days_ago,
select: fragment("round(avg(?))::integer", ps.price_per_round_cents)
)
)
%{
current_min: current_min,
all_time_low: stats.all_time_low,
all_time_high: stats.all_time_high,
thirty_day_avg: thirty_day_avg
thirty_day_avg: stats.thirty_day_avg
}
end

View file

@ -2,20 +2,32 @@ defmodule Ammoprices.Scraping.CaliberMatcher do
@moduledoc false
def match(title, calibers) do
downcased = String.downcase(title)
downcased_title = String.downcase(title)
prepped = Enum.map(calibers, &prepare_caliber/1)
Enum.find(calibers, fn caliber ->
name_matches?(downcased, caliber) or alias_matches?(downcased, caliber)
end)
case Enum.find(prepped, fn caliber ->
name_matches?(downcased_title, caliber) or alias_matches?(downcased_title, caliber)
end) do
nil -> nil
caliber -> caliber.original
end
end
defp prepare_caliber(caliber) do
%{
original: caliber,
name: String.downcase(caliber.name),
aliases: Enum.map(caliber.aliases, &String.downcase/1)
}
end
defp name_matches?(downcased_title, caliber) do
String.contains?(downcased_title, String.downcase(caliber.name))
String.contains?(downcased_title, caliber.name)
end
defp alias_matches?(downcased_title, caliber) do
Enum.any?(caliber.aliases, fn a ->
String.contains?(downcased_title, String.downcase(a))
String.contains?(downcased_title, a)
end)
end
end

View file

@ -127,7 +127,7 @@ defmodule Ammoprices.Scraping.Retailers.BulkAmmo do
end
defp extract_round_count(title) do
case Regex.run(~r/^(\d[\d,]*)\s+[Rr]ounds?/i, title) do
case Regex.run(~r/(\d[\d,]*)\s+[Rr]ounds?/i, title) do
[_, count] -> count |> String.replace(",", "") |> String.to_integer()
_ -> nil
end

View file

@ -60,11 +60,13 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
nil
caliber_value ->
escaped = escape_gql_string(caliber_value)
"""
{
products(
search: "#{caliber_value}"
filter: { caliber: { eq: "#{caliber_value}" } }
search: "#{escaped}"
filter: { caliber: { eq: "#{escaped}" } }
pageSize: 48
currentPage: 1
) {
@ -80,6 +82,13 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
end
end
defp escape_gql_string(str) do
str
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
end
def parse_graphql_response(%{"data" => %{"products" => %{"items" => items}}}) do
items
|> Enum.map(&parse_item/1)
@ -89,33 +98,39 @@ defmodule Ammoprices.Scraping.Retailers.Natchez do
def parse_graphql_response(_), do: []
defp parse_item(item) do
price_cents = extract_price(item)
round_count = parse_integer(item["rounds"])
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
name = item["name"]
case extract_price(item) do
{:ok, price_cents} ->
round_count = parse_integer(item["rounds"])
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
name = item["name"]
%{
title: name,
url: "/#{item["url_key"]}.html",
brand: item["brand"],
price_cents: price_cents,
price_per_round_cents: ppr,
grain_weight: parse_integer(item["grain"]),
round_count: round_count,
casing: parse_casing(item["case_material"]),
subsonic: TextDetector.detect_subsonic(name),
in_stock: item["stock_status"] == "IN_STOCK"
}
%{
title: name,
url: "/#{item["url_key"]}.html",
brand: item["brand"],
price_cents: price_cents,
price_per_round_cents: ppr,
grain_weight: parse_integer(item["grain"]),
round_count: round_count,
casing: parse_casing(item["case_material"]),
subsonic: TextDetector.detect_subsonic(name),
in_stock: item["stock_status"] == "IN_STOCK"
}
:error ->
nil
end
end
defp extract_price(%{"price_range" => %{"minimum_price" => %{"final_price" => %{"value" => value}}}}) do
value
|> String.to_float()
|> Kernel.*(100)
|> round()
{:ok,
value
|> String.to_float()
|> Kernel.*(100)
|> round()}
end
defp extract_price(_), do: 0
defp extract_price(_), do: :error
defp parse_integer(nil), do: nil

View file

@ -45,8 +45,7 @@ defmodule Ammoprices.Scraping.Retailers.PalmettoStateArmory do
end
defp parse_item(item) do
with {:ok, title} <- extract_title(item),
{:ok, url} <- extract_url(item),
with {:ok, title, url} <- extract_link_info(item),
{:ok, price_cents} <- extract_price(item),
{:ok, ppr_cents} <- extract_unit_price(item) do
%{
@ -66,18 +65,13 @@ defmodule Ammoprices.Scraping.Retailers.PalmettoStateArmory do
end
end
defp extract_title(item) do
defp extract_link_info(item) do
case Floki.find(item, ".product-item-link") do
[{_, _, _} = node] -> {:ok, node |> Floki.text() |> String.trim()}
_ -> :error
end
end
[{_, attrs, _} = node] ->
title = node |> Floki.text() |> String.trim()
defp extract_url(item) do
case Floki.find(item, ".product-item-link") do
[{_, attrs, _}] ->
case List.keyfind(attrs, "href", 0) do
{"href", href} -> {:ok, href}
{"href", href} -> {:ok, title, href}
_ -> :error
end

View file

@ -161,7 +161,6 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do
end
defp extract_in_stock(row) do
{_, _attrs, _} = row
classes = [row] |> Floki.attribute("class") |> List.first() || ""
String.contains?(classes, "instock")
end

View file

@ -183,8 +183,8 @@ defmodule Ammoprices.Scraping.Retailers.TargetSportsUsa do
defp extract_in_stock(item) do
case Floki.find(item, ".add-to-cart") do
[{_, _, _} = node] ->
text = node |> Floki.text() |> String.trim()
text == "Add To Cart"
text = node |> Floki.text() |> String.trim() |> String.downcase()
String.contains?(text, "add to cart")
_ ->
false

View file

@ -47,27 +47,25 @@ defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
title = product["title"]
first_variant = List.first(product["variants"])
case first_variant do
nil ->
nil
with variant when not is_nil(variant) <- first_variant,
price_cents when is_integer(price_cents) <- parse_price(variant["price"]) do
round_count = parse_round_count(variant["title"])
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
variant ->
price_cents = parse_price(variant["price"])
round_count = parse_round_count(variant["title"])
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
%{
title: title,
url: "/products/#{product["handle"]}",
brand: product["vendor"],
price_cents: price_cents,
price_per_round_cents: ppr,
grain_weight: extract_grain_weight(title),
round_count: round_count,
casing: TextDetector.detect_casing(title),
subsonic: TextDetector.detect_subsonic(title),
in_stock: variant["available"] == true
}
%{
title: title,
url: "/products/#{product["handle"]}",
brand: product["vendor"],
price_cents: price_cents,
price_per_round_cents: ppr,
grain_weight: extract_grain_weight(title),
round_count: round_count,
casing: TextDetector.detect_casing(title),
subsonic: TextDetector.detect_subsonic(title),
in_stock: variant["available"] == true
}
else
_ -> nil
end
end
@ -78,7 +76,7 @@ defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
|> round()
end
defp parse_price(_), do: 0
defp parse_price(_), do: nil
defp parse_round_count(title) when is_binary(title) do
case Integer.parse(title) do

View file

@ -5,6 +5,8 @@ defmodule Ammoprices.Scraping.Runner do
alias Ammoprices.Prices
alias Ammoprices.Scraping.HttpClient
require Logger
def run(scraper, caliber) do
retailer = Catalog.get_retailer_by_slug!(scraper.retailer_slug())
@ -49,42 +51,57 @@ defmodule Ammoprices.Scraping.Runner do
defp process_results(parsed, retailer, caliber) do
now = DateTime.truncate(DateTime.utc_now(), :second)
results =
Enum.map(parsed, fn product_data ->
product_attrs = %{
title: product_data.title,
url: product_data.url,
brand: product_data.brand,
grain_weight: product_data.grain_weight,
round_count: product_data.round_count,
casing: product_data.casing,
condition: "new",
in_stock: product_data.in_stock,
subsonic: Map.get(product_data, :subsonic, false),
last_seen_at: now
}
case Catalog.upsert_product(retailer.id, caliber.id, product_attrs) do
{:ok, product} ->
snapshot_attrs = %{
price_cents: product_data.price_cents,
price_per_round_cents: product_data.price_per_round_cents,
in_stock: product_data.in_stock,
recorded_at: now
}
{:ok, _snapshot} = Prices.create_snapshot(product.id, snapshot_attrs)
:ok
{:error, _changeset} ->
:error
{_results, successful} =
Enum.reduce(parsed, {[], 0}, fn product_data, {acc, count} ->
case process_product(product_data, retailer, caliber, now) do
:ok -> {[:ok | acc], count + 1}
:error -> {[:error | acc], count}
end
end)
successful = Enum.count(results, &(&1 == :ok))
if successful > 0 do
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, now)
end
Catalog.update_retailer(retailer, %{last_scraped_at: now})
{:ok, %{products_count: successful, snapshots_count: successful}}
end
defp process_product(product_data, retailer, caliber, now) do
product_attrs = %{
title: product_data.title,
url: product_data.url,
brand: product_data.brand,
grain_weight: product_data.grain_weight,
round_count: product_data.round_count,
casing: product_data.casing,
condition: "new",
in_stock: product_data.in_stock,
subsonic: Map.get(product_data, :subsonic, false),
last_seen_at: now
}
with {:ok, product} <- Catalog.upsert_product(retailer.id, caliber.id, product_attrs),
{:ok, _snapshot} <- create_snapshot(product, product_data, now) do
:ok
else
{:error, %Ecto.Changeset{} = changeset} ->
Logger.warning(
"Scrape insert failed for #{retailer.slug} #{product_data.url}: " <>
inspect(changeset.errors)
)
:error
end
end
defp create_snapshot(product, product_data, now) do
Prices.create_snapshot(product.id, %{
price_cents: product_data.price_cents,
price_per_round_cents: product_data.price_per_round_cents,
in_stock: product_data.in_stock,
recorded_at: now
})
end
end

View file

@ -2,12 +2,13 @@ defmodule Ammoprices.Scraping.ScrapeJob do
@moduledoc false
use Oban.Worker,
queue: :scraping,
max_attempts: 3,
unique: [period: :infinity, states: [:available, :scheduled, :executing, :retryable]]
max_attempts: 3
alias Ammoprices.Catalog
alias Ammoprices.Scraping.Runner
require Logger
@scrapers [
Ammoprices.Scraping.Retailers.LuckyGunner,
Ammoprices.Scraping.Retailers.SgAmmo,
@ -18,20 +19,45 @@ defmodule Ammoprices.Scraping.ScrapeJob do
Ammoprices.Scraping.Retailers.Natchez
]
# Products not seen for this many days are hard-deleted after each scrape cycle.
@stale_product_days 30
@impl Oban.Worker
def perform(_job) do
calibers = Catalog.list_calibers()
caliber_ids = Enum.map(calibers, & &1.id)
for scraper <- @scrapers, caliber <- calibers do
scrape_delay()
Runner.run(scraper, caliber)
safe_run(scraper, caliber)
end
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}})
prune_stale_products()
schedule_next()
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, caliber_ids})
:ok
after
# Always reseed the chain, even if something above crashed. Without this,
# a single unhandled error kills the chain until the app is restarted.
schedule_next()
end
defp safe_run(scraper, caliber) do
Runner.run(scraper, caliber)
rescue
error ->
Logger.error(
"Scrape crashed for #{inspect(scraper)} / #{caliber.slug}: " <>
Exception.format(:error, error, __STACKTRACE__)
)
{:error, :crashed}
end
defp prune_stale_products do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_product_days, :day)
Catalog.delete_stale_products(cutoff)
end
@doc false

View file

@ -12,8 +12,8 @@ defmodule Ammoprices.Scraping.TextDetector do
cond do
String.contains?(downcased, "steel") -> "steel"
String.contains?(downcased, "aluminum") -> "aluminum"
String.contains?(downcased, "nickel") -> "brass"
String.contains?(downcased, "brass") -> "brass"
String.contains?(downcased, "nickel") -> "brass"
true -> nil
end
end

View file

@ -29,6 +29,12 @@
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
<script async src="https://a.w5isp.com/js/pa-SCr2Xg76FIMrHVz1QtH0_.js">
</script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
{@inner_content}

View file

@ -103,15 +103,19 @@ defmodule AmmopricesWeb.CaliberLive.Show do
end
@impl true
def handle_info({:prices_updated, _}, socket) do
socket =
socket
|> load_filter_options()
|> load_products()
|> load_chart_data()
|> load_price_stats()
def handle_info({:prices_updated, caliber_ids}, socket) do
if socket.assigns.caliber.id in caliber_ids do
socket =
socket
|> load_filter_options()
|> load_products()
|> load_chart_data()
|> load_price_stats()
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
else
{:noreply, socket}
end
end
defp load_filter_options(socket) do
@ -178,14 +182,18 @@ defmodule AmmopricesWeb.CaliberLive.Show do
days = Map.get(@range_days, range, 30)
daily_data = Prices.daily_averages_for_caliber(caliber.id, days: days)
{labels, avgs, mins} =
Enum.reduce(daily_data, {[], [], []}, fn d, {ls, as, ms} ->
{[Date.to_iso8601(d.date) | ls], [d.avg_ppr | as], [d.min_ppr | ms]}
end)
chart_data = %{
labels: Enum.map(daily_data, &Date.to_iso8601(&1.date)),
avg: Enum.map(daily_data, & &1.avg_ppr),
min: Enum.map(daily_data, & &1.min_ppr),
max: Enum.map(daily_data, & &1.max_ppr)
labels: Enum.reverse(labels),
avg: Enum.reverse(avgs),
min: Enum.reverse(mins)
}
assign(socket, :chart_data, chart_data)
assign(socket, chart_data: chart_data, chart_empty?: daily_data == [])
end
defp load_price_stats(socket) do
@ -279,13 +287,25 @@ defmodule AmmopricesWeb.CaliberLive.Show do
</button>
</div>
</div>
<div
id="price-chart-container"
phx-hook=".PriceChart"
data-chart-data={Jason.encode!(@chart_data)}
class="h-64 sm:h-80"
>
<canvas id="price-chart" class="w-full h-full"></canvas>
<div class="relative h-64 sm:h-80">
<div
id="price-chart-container"
phx-hook=".PriceChart"
phx-update="ignore"
data-chart-data={Jason.encode!(@chart_data)}
class="w-full h-full"
>
<canvas id="price-chart" class="w-full h-full"></canvas>
</div>
<div
:if={@chart_empty?}
id="price-chart-empty"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<p class="text-sm text-base-content/40">
No price history available for this range yet.
</p>
</div>
</div>
</div>
@ -503,10 +523,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
const ctx = canvas.getContext("2d")
// Get CSS custom property values for theming
const style = getComputedStyle(document.documentElement)
const textColor = style.getPropertyValue("color") || "#666"
return new Chart(ctx, {
type: "line",
data: {

View file

@ -16,9 +16,11 @@ defmodule AmmopricesWeb.HomeLive do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated")
end
calibers = Catalog.list_calibers()
cheapest = Prices.cheapest_per_caliber()
cheapest_map = Map.new(cheapest, fn r -> {r.caliber_id, r.min_ppr} end)
grouped =
calibers
@ -28,12 +30,25 @@ defmodule AmmopricesWeb.HomeLive do
end)
{:ok,
assign(socket,
socket
|> assign(
page_title: "AmmoCPR",
categories: @categories,
calibers_by_category: grouped,
cheapest_map: cheapest_map
)}
calibers_by_category: grouped
)
|> assign_cheapest_map()}
end
@impl true
def handle_info({:prices_updated, _caliber_ids}, socket) do
{:noreply, assign_cheapest_map(socket)}
end
defp assign_cheapest_map(socket) do
cheapest_map =
Map.new(Prices.cheapest_per_caliber(), fn r -> {r.caliber_id, r.min_ppr} end)
assign(socket, :cheapest_map, cheapest_map)
end
@impl true

View file

@ -1,51 +1,50 @@
%{
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
"phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}

View file

@ -0,0 +1,11 @@
defmodule Ammoprices.Repo.Migrations.UpgradeObanJobsV14 do
use Ecto.Migration
def up do
Oban.Migration.up(version: 14)
end
def down do
Oban.Migration.down(version: 12)
end
end

View file

@ -4,6 +4,86 @@ defmodule Ammoprices.CatalogTest do
import Ammoprices.Fixtures
alias Ammoprices.Catalog
alias Ammoprices.Catalog.Product
alias Ammoprices.Repo
describe "mark_unseen_products_out_of_stock/3" do
test "marks products in scope with last_seen_at < cutoff as out of stock" do
retailer = retailer_fixture()
caliber = caliber_fixture()
cutoff = ~U[2026-04-22 12:00:00Z]
old = ~U[2026-04-22 11:00:00Z]
new = ~U[2026-04-22 12:30:00Z]
stale = product_fixture(%{retailer: retailer, caliber: caliber, last_seen_at: old, in_stock: true})
fresh = product_fixture(%{retailer: retailer, caliber: caliber, last_seen_at: new, in_stock: true})
assert {count, _} = Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
assert count == 1
assert Repo.get!(Product, stale.id).in_stock == false
assert Repo.get!(Product, fresh.id).in_stock == true
end
test "does not affect products from other retailers" do
retailer = retailer_fixture()
other_retailer = retailer_fixture(%{slug: "other"})
caliber = caliber_fixture()
cutoff = ~U[2026-04-22 12:00:00Z]
old = ~U[2026-04-22 11:00:00Z]
other_stale =
product_fixture(%{retailer: other_retailer, caliber: caliber, last_seen_at: old, in_stock: true})
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
assert Repo.get!(Product, other_stale.id).in_stock == true
end
test "does not affect products from other calibers" do
retailer = retailer_fixture()
caliber = caliber_fixture()
other_caliber = caliber_fixture(%{slug: "other"})
cutoff = ~U[2026-04-22 12:00:00Z]
old = ~U[2026-04-22 11:00:00Z]
other_stale =
product_fixture(%{retailer: retailer, caliber: other_caliber, last_seen_at: old, in_stock: true})
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, cutoff)
assert Repo.get!(Product, other_stale.id).in_stock == true
end
end
describe "delete_stale_products/1" do
test "deletes products whose last_seen_at is older than cutoff" do
cutoff = ~U[2026-04-22 12:00:00Z]
old = ~U[2026-03-22 00:00:00Z]
new = ~U[2026-04-22 12:30:00Z]
stale = product_fixture(%{last_seen_at: old})
fresh = product_fixture(%{last_seen_at: new})
assert {count, _} = Catalog.delete_stale_products(cutoff)
assert count == 1
assert_raise Ecto.NoResultsError, fn -> Repo.get!(Product, stale.id) end
assert Repo.get!(Product, fresh.id)
end
test "cascades to delete related price snapshots" do
old = ~U[2026-03-22 00:00:00Z]
cutoff = ~U[2026-04-22 12:00:00Z]
stale = product_fixture(%{last_seen_at: old})
snapshot = snapshot_fixture(%{product: stale})
Catalog.delete_stale_products(cutoff)
refute Repo.get(Ammoprices.Prices.PriceSnapshot, snapshot.id)
end
end
describe "retailers" do
test "list_retailers/0 returns all retailers" do

View file

@ -4,6 +4,8 @@ defmodule Ammoprices.Scraping.RunnerTest do
import Ammoprices.Fixtures
alias Ammoprices.Catalog
alias Ammoprices.Catalog.Product
alias Ammoprices.Scraping.HttpClient
alias Ammoprices.Scraping.Retailers.LuckyGunner
alias Ammoprices.Scraping.Runner
@ -13,7 +15,7 @@ defmodule Ammoprices.Scraping.RunnerTest 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(Ammoprices.Scraping.HttpClient, fn conn ->
Req.Test.stub(HttpClient, fn conn ->
Req.Test.html(conn, @fixture_html)
end)
@ -48,5 +50,112 @@ defmodule Ammoprices.Scraping.RunnerTest do
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

View file

@ -4,6 +4,7 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
import Ammoprices.Fixtures
alias Ammoprices.Scraping.HttpClient
alias Ammoprices.Scraping.ScrapeJob
@fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html")
@ -44,7 +45,7 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
Req.Test.stub(HttpClient, fn conn ->
Req.Test.html(conn, @fixture_html)
end)
@ -55,5 +56,46 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
test "executes scrape for all enabled scrapers and calibers" do
assert :ok = perform_job(ScrapeJob, %{})
end
test "survives HttpClient crashes and still schedules the next job" do
import Ecto.Query
Req.Test.stub(HttpClient, fn _conn ->
raise "simulated network crash"
end)
assert :ok = perform_job(ScrapeJob, %{})
scheduled_count =
Ammoprices.Repo.aggregate(
from(j in "oban_jobs",
where: j.worker == "Ammoprices.Scraping.ScrapeJob" and j.state == "scheduled"
),
:count,
:id
)
assert scheduled_count >= 1
end
test "hard-deletes products not seen in 30+ days after the scrape cycle" do
retailer = Ammoprices.Catalog.get_retailer_by_slug!("lucky-gunner")
caliber = Ammoprices.Catalog.get_caliber_by_slug!("9mm-luger")
long_ago = DateTime.utc_now() |> DateTime.add(-31, :day) |> DateTime.truncate(:second)
stale =
product_fixture(%{
retailer: retailer,
caliber: caliber,
url: "/products/ancient",
last_seen_at: long_ago,
in_stock: false
})
assert :ok = perform_job(ScrapeJob, %{})
refute Ammoprices.Repo.get(Ammoprices.Catalog.Product, stale.id)
end
end
end

View file

@ -182,6 +182,38 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
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})
@ -196,7 +228,7 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
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, %{}})
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, [caliber.id]})
# The view should re-render with updated data
html = render(view)

View file

@ -33,5 +33,19 @@ defmodule AmmopricesWeb.HomeLiveTest do
assert html =~ "25"
end
test "updates cheapest prices via PubSub broadcast", %{conn: conn} do
caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"})
retailer = retailer_fixture()
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
now = DateTime.truncate(DateTime.utc_now(), :second)
{:ok, view, _html} = live(conn, "/")
snapshot_fixture(%{product: product, price_per_round_cents: 17, in_stock: true, recorded_at: now})
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, [caliber.id]})
assert render(view) =~ "0.17"
end
end
end