ammocpr/test/ammoprices/catalog_test.exs
Graham McIntire 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

333 lines
11 KiB
Elixir

defmodule Ammoprices.CatalogTest do
use Ammoprices.DataCase, async: true
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
retailer = retailer_fixture()
assert retailer in Catalog.list_retailers()
end
test "get_retailer!/1 returns the retailer with given id" do
retailer = retailer_fixture()
assert Catalog.get_retailer!(retailer.id) == retailer
end
test "get_retailer_by_slug!/1 returns the retailer with given slug" do
retailer = retailer_fixture(%{slug: "lucky-gunner"})
assert Catalog.get_retailer_by_slug!("lucky-gunner") == retailer
end
test "create_retailer/1 with valid data creates a retailer" do
attrs = %{name: "New Retailer", slug: "new-retailer", base_url: "https://newretailer.com"}
assert {:ok, retailer} = Catalog.create_retailer(attrs)
assert retailer.name == "New Retailer"
assert retailer.slug == "new-retailer"
assert retailer.base_url == "https://newretailer.com"
assert retailer.enabled == true
end
test "create_retailer/1 with duplicate slug returns error" do
retailer_fixture(%{slug: "duplicate"})
attrs = %{name: "Another", slug: "duplicate", base_url: "https://example.com"}
assert {:error, changeset} = Catalog.create_retailer(attrs)
assert %{slug: ["has already been taken"]} = errors_on(changeset)
end
test "create_retailer/1 with missing required fields returns error" do
assert {:error, changeset} = Catalog.create_retailer(%{})
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
test "update_retailer/2 updates the retailer" do
retailer = retailer_fixture()
assert {:ok, updated} = Catalog.update_retailer(retailer, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
end
describe "calibers" do
test "list_calibers/0 returns all calibers" do
caliber = caliber_fixture()
assert caliber in Catalog.list_calibers()
end
test "list_calibers_by_category/1 filters by category" do
handgun = caliber_fixture(%{name: "9mm", slug: "9mm", category: "handgun"})
_rifle = caliber_fixture(%{name: "5.56", slug: "556", category: "rifle"})
result = Catalog.list_calibers_by_category("handgun")
assert handgun in result
refute Enum.any?(result, &(&1.category != "handgun"))
end
test "get_caliber!/1 returns the caliber with given id" do
caliber = caliber_fixture()
assert Catalog.get_caliber!(caliber.id) == caliber
end
test "get_caliber_by_slug!/1 returns the caliber with given slug" do
caliber = caliber_fixture(%{slug: "9mm-luger"})
assert Catalog.get_caliber_by_slug!("9mm-luger") == caliber
end
test "create_caliber/1 with valid data creates a caliber" do
attrs = %{
name: ".50 AE",
slug: "50-ae",
category: "handgun",
aliases: [".50 ae", "50 action express"]
}
assert {:ok, caliber} = Catalog.create_caliber(attrs)
assert caliber.name == ".50 AE"
assert caliber.aliases == [".50 ae", "50 action express"]
end
test "create_caliber/1 with invalid category returns error" do
attrs = %{name: "Test", slug: "test", category: "invalid"}
assert {:error, changeset} = Catalog.create_caliber(attrs)
assert %{category: ["is invalid"]} = errors_on(changeset)
end
end
describe "products" do
test "upsert_product/3 creates a new product" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 115gr FMJ",
url: "/products/federal-9mm",
brand: "Federal",
grain_weight: 115,
round_count: 50,
casing: "brass",
condition: "new",
in_stock: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert product.title == "Federal 9mm 115gr FMJ"
assert product.retailer_id == retailer.id
assert product.caliber_id == caliber.id
end
test "upsert_product/3 updates existing product on duplicate retailer_id + url" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 115gr FMJ",
url: "/products/federal-9mm",
brand: "Federal",
grain_weight: 115,
round_count: 50,
casing: "brass",
in_stock: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product1} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert {:ok, product2} =
Catalog.upsert_product(retailer.id, caliber.id, %{attrs | title: "Updated Title", in_stock: false})
assert product1.id == product2.id
assert product2.title == "Updated Title"
assert product2.in_stock == false
end
test "upsert_product/3 stores subsonic flag" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 147gr Subsonic HP",
url: "/products/federal-subsonic",
brand: "Federal",
grain_weight: 147,
round_count: 50,
casing: "brass",
condition: "new",
in_stock: true,
subsonic: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert product.subsonic == true
end
test "upsert_product/3 updates subsonic on conflict" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 147gr HP",
url: "/products/federal-hp",
brand: "Federal",
grain_weight: 147,
round_count: 50,
casing: "brass",
in_stock: true,
subsonic: false,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, _} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert {:ok, updated} =
Catalog.upsert_product(retailer.id, caliber.id, %{attrs | subsonic: true})
assert updated.subsonic == true
end
test "list_products_for_caliber/2 returns products for a caliber" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product = product_fixture(%{caliber: caliber, retailer: retailer})
_other = product_fixture()
results = Catalog.list_products_for_caliber(caliber.id)
assert length(results) == 1
assert hd(results).id == product.id
end
end
describe "distinct_grain_weights_for_caliber/1" do
test "returns sorted unique grain weights" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 124})
result = Catalog.distinct_grain_weights_for_caliber(caliber.id)
assert result == [115, 124, 147]
end
test "excludes nil grain weights" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: nil})
result = Catalog.distinct_grain_weights_for_caliber(caliber.id)
assert result == [115]
end
end
describe "distinct_casings_for_caliber/1" do
test "returns sorted unique casings" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "aluminum"})
result = Catalog.distinct_casings_for_caliber(caliber.id)
assert result == ["aluminum", "brass", "steel"]
end
test "excludes nil casings" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: nil})
result = Catalog.distinct_casings_for_caliber(caliber.id)
assert result == ["brass"]
end
end
end