commit bbe5bde14561f3f86181b731d0f87ed2db2a1646 Author: Graham McIntire Date: Wed Mar 11 15:55:47 2026 -0500 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 diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..18b26c5 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Styler, Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6c9176 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +ammoprices-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e1fe7a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,449 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + +### JS and CSS guidelines + +- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces. +- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`: + + @import "tailwindcss" source(none); + @source "../css"; + @source "../js"; + @source "../../lib/my_app_web"; + +- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new` +- **Never** use `@apply` when writing raw css +- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design +- Out of the box **only the app.js and app.css bundles are supported** + - You cannot reference an external vendor'd script `src` or link `href` in the layouts + - You must import the vendor deps into app.js and app.css to use them + - **Never write inline tags within templates** + +### UI/UX & design guidelines + +- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles +- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions) +- Ensure **clean typography, spacing, and layout balance** for a refined, premium look +- Focus on **delightful details** like hover effects, loading states, and smooth page transitions + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + +## Test guidelines + +- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests +- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests + - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + + - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct +- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied + + + +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` + +### LiveView streams + +- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations: + - basic append of N items - `stream(socket, :messages, [new_msg])` + - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items) + - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)` + - deleting items - `stream_delete(socket, :messages, msg)` + +- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be: + +
+
+ {msg.text} +
+
+ +- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: + + def handle_event("filter", %{"filter" => filter}, socket) do + # re-fetch the messages based on the filter + messages = list_messages(filter) + + {:noreply, + socket + |> assign(:messages_empty?, messages == []) + # reset the stream with the new messages + |> stream(:messages, messages, reset: true)} + end + +- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: + +
+ +
+ {task.name} +
+
+ + The above only works if the empty state is the only HTML block alongside the stream for-comprehension. + +- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items + along with the updated assign: + + def handle_event("edit_message", %{"message_id" => message_id}, socket) do + message = Chat.get_message!(message_id) + edit_form = to_form(Chat.change_message(message, %{content: message.content})) + + # re-insert message so @editing_message_id toggle logic takes effect for that stream item + {:noreply, + socket + |> stream_insert(:messages, message) + |> assign(:editing_message_id, String.to_integer(message_id)) + |> assign(:edit_form, edit_form)} + end + + And in the template: + +
+
+ {message.username} + <%= if @editing_message_id == message.id do %> + <%!-- Edit mode --%> + <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> + ... + + <% end %> +
+
+ +- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections + +### LiveView JavaScript interop + +- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised + +LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, +and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. + +#### Inline colocated js hooks + +**Never** write raw embedded ` + +- colocated hooks are automatically integrated into the app.js bundle +- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` + +#### External phx-hook + +External JS hooks (`
`) must be placed in `assets/js/` and passed to the +LiveSocket constructor: + + const MyHook = { + mounted() { ... } + } + let liveSocket = new LiveSocket("/live", Socket, { + hooks: { MyHook } + }); + +#### Pushing events between client and server + +Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. +**Always** return or rebind the socket on `push_event/3` when pushing events: + + # re-bind socket so we maintain event state to be pushed + socket = push_event(socket, "my_event", %{...}) + + # or return the modified socket directly: + def handle_event("some_event", _, socket) do + {:noreply, push_event(socket, "my_event", %{...})} + end + +Pushed events can then be picked up in a JS hook with `this.handleEvent`: + + mounted() { + this.handleEvent("my_event", data => console.log("from server:", data)); + } + +Clients can also push an event to the server and receive a reply with `this.pushEvent`: + + mounted() { + this.el.addEventListener("click", e => { + this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); + }) + } + +Where the server handled it via: + + def handle_event("my_event", %{"one" => 1}, socket) do + {:reply, %{two: 2}, socket} + end + +### LiveView tests + +- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions +- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions +- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests +- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc +- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")` +- Instead of relying on testing text content, which can change, favor testing for the presence of key elements +- Focus on testing outcomes rather than implementation details +- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be +- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie: + + html = render(view) + document = LazyHTML.from_fragment(html) + matches = LazyHTML.filter(document, "your-complex-selector") + IO.inspect(matches, label: "Matches") + +### Form handling + +#### Creating a form from params + +If you want to create a form based on `handle_event` params: + + def handle_event("submitted", params, socket) do + {:noreply, assign(socket, form: to_form(params))} + end + +When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys. + +You can also specify a name to nest the params: + + def handle_event("submitted", %{"user" => user_params}, socket) do + {:noreply, assign(socket, form: to_form(user_params, as: :user))} + end + +#### Creating a form from changesets + +When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema: + + defmodule MyApp.Users.User do + use Ecto.Schema + ... + end + +And then you create a changeset that you pass to `to_form`: + + %MyApp.Users.User{} + |> Ecto.Changeset.change() + |> to_form() + +Once the form is submitted, the params will be available under `%{"user" => user_params}`. + +In the template, the form form assign can be passed to the `<.form>` function component: + + <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:field]} type="text" /> + + +Always give the form an explicit, unique DOM ID, like `id="todo-form"`. + +#### Avoiding form errors + +**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**: + + <%!-- ALWAYS do this (valid) --%> + <.form for={@form} id="my-form"> + <.input field={@form[:field]} type="text" /> + + +And **never** do this: + + <%!-- NEVER do this (invalid) --%> + <.form for={@changeset} id="my-form"> + <.input field={@changeset[:field]} type="text" /> + + +- You are FORBIDDEN from accessing the changeset in the template as it will cause errors +- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..784dfef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Ammoprices is a Phoenix 1.8 web application (Elixir ~> 1.15) with LiveView, Ecto/PostgreSQL, and Tailwind CSS v4 with daisyUI. Currently a fresh scaffold with no domain logic implemented yet. + +## Common Commands + +```bash +mix setup # Install deps, create DB, run migrations, build assets +mix phx.server # Start dev server (localhost:4000) +iex -S mix phx.server # Start dev server with IEx shell +mix test # Run all tests (auto-creates/migrates test DB) +mix test test/path_test.exs # Run a single test file +mix test test/path_test.exs:42 # Run a specific test by line number +mix test --failed # Re-run previously failed tests +mix precommit # Compile (warnings-as-errors) + unlock unused deps + format + test +mix format # Format code +mix ecto.gen.migration name # Generate a new migration +mix ecto.migrate # Run pending migrations +mix ecto.reset # Drop and recreate database +``` + +`mix precommit` is the required pre-commit check — always run it before finishing changes. + +## Architecture + +### Application Structure + +- **`Ammoprices.Application`** — Supervision tree: Telemetry, Repo, DNSCluster, PubSub, Endpoint +- **`Ammoprices.Repo`** — Ecto repo using PostgreSQL adapter +- **`Ammoprices.Mailer`** — Swoosh mailer (local adapter in dev, viewable at `/dev/mailbox`) + +### Web Layer (`lib/ammoprices_web/`) + +- **`AmmopricesWeb`** (`ammoprices_web.ex`) — Defines `use` macros for `:router`, `:controller`, `:live_view`, `:live_component`, `:html`. The `html_helpers/0` block imports `CoreComponents`, aliases `Layouts` and `Phoenix.LiveView.JS`, and sets up verified routes +- **`AmmopricesWeb.Router`** — Browser pipeline only. Currently just `GET / → PageController.home` +- **`AmmopricesWeb.CoreComponents`** — UI component library using daisyUI + Tailwind. Includes `flash/1`, `button/1`, `icon/1`, standard form/table components +- **`AmmopricesWeb.Layouts`** — `app/1` layout with navbar and theme toggle (light/dark/system). Templates wrap content with `` + +### Config + +- **`config/config.exs`** — Generator defaults: UTC timestamps, binary IDs +- **`config/dev.exs`** — DB: `ammoprices_dev`, live reload watchers for esbuild/tailwind +- **`config/test.exs`** — DB: `ammoprices_test`, SQL sandbox mode + +### Assets (`assets/`) + +- **Tailwind CSS v4** with `@import "tailwindcss"` syntax (no `tailwind.config.js`) +- **daisyUI** plugin with custom light/dark themes defined in `app.css` +- **esbuild** bundles `app.js` — only `app.js` and `app.css` bundles are supported +- Vendor libs: `topbar.js`, `heroicons.js`, `daisyui.js`, `daisyui-theme.js` +- JS hooks for LiveView use colocated hook syntax (names prefixed with `.`) + +### Test Support (`test/support/`) + +- **`ConnCase`** — For controller/LiveView tests, sets up conn and DB sandbox +- **`DataCase`** — For context/schema tests, provides `errors_on/1` helper +- **`LazyHTML`** — Available in tests for HTML assertion selectors + +## Key Conventions + +- **HTTP client**: Use `Req` (already included). Never use HTTPoison, Tesla, or `:httpc` +- **LiveView templates**: Always begin with `` wrapper +- **Icons**: Use `<.icon name="hero-x-mark" />` component, never Heroicons modules +- **Forms**: Always use `to_form/2` assigned in LiveView, access via `@form[:field]` in templates +- **Collections**: Always use LiveView streams, never assign raw lists +- **JS in templates**: Use colocated hook ` + + + + {@inner_content} + + diff --git a/lib/ammoprices_web/components/price_components.ex b/lib/ammoprices_web/components/price_components.ex new file mode 100644 index 0000000..ed0b14d --- /dev/null +++ b/lib/ammoprices_web/components/price_components.ex @@ -0,0 +1,79 @@ +defmodule AmmopricesWeb.PriceComponents do + @moduledoc false + use Phoenix.Component + + attr :cents, :integer, required: true + attr :class, :string, default: nil + + def price_per_round(assigns) do + ~H""" + + {format_cpr(@cents)} + + """ + end + + attr :in_stock, :boolean, required: true + attr :class, :string, default: nil + + def stock_badge(assigns) do + ~H""" + + + {if @in_stock, do: "In Stock", else: "Out of Stock"} + + """ + end + + attr :url, :string, required: true + attr :name, :string, required: true + attr :class, :string, default: nil + + def retailer_link(assigns) do + ~H""" + + {@name} + + + + + """ + end + + defp format_cpr(nil), do: "--" + + defp format_cpr(cents) when cents < 100 do + "#{cents}¢/rd" + end + + defp format_cpr(cents) do + dollars = cents / 100 + "$#{:erlang.float_to_binary(dollars, decimals: 2)}/rd" + end +end diff --git a/lib/ammoprices_web/controllers/error_html.ex b/lib/ammoprices_web/controllers/error_html.ex new file mode 100644 index 0000000..272e141 --- /dev/null +++ b/lib/ammoprices_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule AmmopricesWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use AmmopricesWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/ammoprices_web/controllers/error_html/404.html.heex + # * lib/ammoprices_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/ammoprices_web/controllers/error_json.ex b/lib/ammoprices_web/controllers/error_json.ex new file mode 100644 index 0000000..eadeba3 --- /dev/null +++ b/lib/ammoprices_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule AmmopricesWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/ammoprices_web/controllers/page_controller.ex b/lib/ammoprices_web/controllers/page_controller.ex new file mode 100644 index 0000000..f1b9cff --- /dev/null +++ b/lib/ammoprices_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule AmmopricesWeb.PageController do + use AmmopricesWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/lib/ammoprices_web/controllers/page_html.ex b/lib/ammoprices_web/controllers/page_html.ex new file mode 100644 index 0000000..96f7e0f --- /dev/null +++ b/lib/ammoprices_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule AmmopricesWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use AmmopricesWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/ammoprices_web/controllers/page_html/home.html.heex b/lib/ammoprices_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..b107fd0 --- /dev/null +++ b/lib/ammoprices_web/controllers/page_html/home.html.heex @@ -0,0 +1,202 @@ + + +
+
+ +
+

+ Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + +

+ +
+ +

+ Peace of mind from prototype to production. +

+

+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +

+ +
+
diff --git a/lib/ammoprices_web/endpoint.ex b/lib/ammoprices_web/endpoint.ex new file mode 100644 index 0000000..09da565 --- /dev/null +++ b/lib/ammoprices_web/endpoint.ex @@ -0,0 +1,55 @@ +defmodule AmmopricesWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :ammoprices + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_ammoprices_key", + signing_salt: "4zemBm8P", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :ammoprices, + gzip: not code_reloading?, + only: AmmopricesWeb.static_paths(), + raise_on_missing_only: code_reloading? + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :ammoprices + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug AmmopricesWeb.Router +end diff --git a/lib/ammoprices_web/gettext.ex b/lib/ammoprices_web/gettext.ex new file mode 100644 index 0000000..2cbbf2d --- /dev/null +++ b/lib/ammoprices_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule AmmopricesWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: AmmopricesWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :ammoprices +end diff --git a/lib/ammoprices_web/live/caliber_live/show.ex b/lib/ammoprices_web/live/caliber_live/show.ex new file mode 100644 index 0000000..951b85d --- /dev/null +++ b/lib/ammoprices_web/live/caliber_live/show.ex @@ -0,0 +1,471 @@ +defmodule AmmopricesWeb.CaliberLive.Show do + @moduledoc false + use AmmopricesWeb, :live_view + + import AmmopricesWeb.PriceComponents + + alias Ammoprices.Catalog + alias Ammoprices.Prices + + @range_days %{ + "7d" => 7, + "30d" => 30, + "90d" => 90, + "1y" => 365, + "all" => 3650 + } + + @impl true + def mount(%{"slug" => slug}, _session, socket) do + caliber = Catalog.get_caliber_by_slug!(slug) + + if connected?(socket) do + Phoenix.PubSub.subscribe(Ammoprices.PubSub, "prices:updated") + end + + socket = + socket + |> assign( + page_title: caliber.name, + caliber: caliber, + in_stock_filter: true, + chart_range: "30d" + ) + |> load_products() + |> load_chart_data() + |> load_price_stats() + + {:ok, socket} + end + + @impl true + def handle_event("toggle_stock_filter", _params, socket) do + socket = + socket + |> assign(:in_stock_filter, !socket.assigns.in_stock_filter) + |> load_products() + + {:noreply, socket} + end + + @impl true + def handle_event("change_range", %{"range" => range}, socket) do + socket = + socket + |> assign(:chart_range, range) + |> load_chart_data() + + {:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})} + end + + @impl true + def handle_info({:prices_updated, _}, socket) do + socket = + socket + |> load_products() + |> load_chart_data() + |> load_price_stats() + + {:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})} + end + + defp load_products(socket) do + %{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns + + opts = + if in_stock_filter do + [in_stock: true, limit: 200] + else + [limit: 200] + end + + snapshots = Prices.latest_prices_for_caliber(caliber.id, opts) + + # Preload product+retailer for each snapshot + product_ids = Enum.map(snapshots, & &1.product_id) + + products_map = + if product_ids == [] do + %{} + else + import Ecto.Query + + from(p in Catalog.Product, + where: p.id in ^product_ids, + preload: [:retailer] + ) + |> Ammoprices.Repo.all() + |> Map.new(&{&1.id, &1}) + end + + rows = + Enum.map(snapshots, fn snap -> + product = Map.get(products_map, snap.product_id) + + %{ + id: snap.id, + product: product, + price_cents: snap.price_cents, + price_per_round_cents: snap.price_per_round_cents, + in_stock: snap.in_stock + } + end) + + stream(socket, :products, rows, reset: true) + end + + defp load_chart_data(socket) do + %{caliber: caliber, chart_range: range} = socket.assigns + days = Map.get(@range_days, range, 30) + daily_data = Prices.daily_averages_for_caliber(caliber.id, days: days) + + 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) + } + + assign(socket, :chart_data, chart_data) + end + + defp load_price_stats(socket) do + stats = Prices.price_stats_for_caliber(socket.assigns.caliber.id) + assign(socket, :price_stats, stats) + end + + defp format_stat_cpr(nil), do: "--" + + defp format_stat_cpr(cents) when cents < 100 do + "#{cents}\u00A2" + end + + defp format_stat_cpr(cents) do + "$#{:erlang.float_to_binary(cents / 100, decimals: 2)}" + end + + @impl true + def render(assigns) do + ~H""" + +
+ <%!-- Breadcrumb + Heading --%> +
+ +

+ {@caliber.name} +

+
+ + <%!-- Price Stats Banner --%> +
+
+
+ Current Low +
+
+ {format_stat_cpr(@price_stats.current_min)} +
+
+
+
+ 30-Day Avg +
+
+ {format_stat_cpr(@price_stats.thirty_day_avg)} +
+
+
+
+ All-Time Low +
+
+ {format_stat_cpr(@price_stats.all_time_low)} +
+
+
+
+ All-Time High +
+
+ {format_stat_cpr(@price_stats.all_time_high)} +
+
+
+ + <%!-- Price Chart --%> +
+
+

+ Price History +

+
+ +
+
+
+ +
+
+ + <%!-- Filters --%> +
+ +
+ + <%!-- Product Table --%> +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Product + + CPR +
+ + {row.product.title} + +
+ {row.product.retailer.name} +
+
+ <.price_per_round + cents={row.price_per_round_cents} + class="text-sm font-bold text-success" + /> +
+ + +
+ + <%!-- Colocated Chart.js Hook --%> + +
+
+ """ + end +end diff --git a/lib/ammoprices_web/live/home_live.ex b/lib/ammoprices_web/live/home_live.ex new file mode 100644 index 0000000..ac95e30 --- /dev/null +++ b/lib/ammoprices_web/live/home_live.ex @@ -0,0 +1,88 @@ +defmodule AmmopricesWeb.HomeLive do + @moduledoc false + use AmmopricesWeb, :live_view + + import AmmopricesWeb.PriceComponents + + alias Ammoprices.Catalog + alias Ammoprices.Prices + + @categories [ + %{id: "handgun", label: "Handgun", icon: "hero-bolt"}, + %{id: "rifle", label: "Rifle", icon: "hero-signal"}, + %{id: "rimfire", label: "Rimfire", icon: "hero-fire"}, + %{id: "shotgun", label: "Shotgun", icon: "hero-shield-check"} + ] + + @impl true + def mount(_params, _session, socket) do + 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 + |> Enum.group_by(& &1.category) + |> Map.new(fn {cat, cals} -> + {cat, Enum.sort_by(cals, & &1.name)} + end) + + {:ok, + assign(socket, + page_title: "Ammo Price Tracker", + categories: @categories, + calibers_by_category: grouped, + cheapest_map: cheapest_map + )} + end + + @impl true + def render(assigns) do + ~H""" + +
+ <%!-- Hero --%> +
+

+ Ammo Price Tracker +

+

+ Real-time ammunition prices from top retailers. Find the cheapest rounds, track price history. +

+
+ + <%!-- Category grid --%> +
+
+
+ <.icon name={cat.icon} class="w-5 h-5 text-primary" /> +

+ {cat.label} +

+
+ +
+ <.link + :for={cal <- Map.get(@calibers_by_category, cat.id, [])} + navigate={~p"/calibers/#{cal.slug}"} + id={"caliber-#{cal.slug}"} + class="group flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg border border-base-300 bg-base-100 hover:border-primary/40 hover:bg-primary/5 transition-all duration-150" + > + + {cal.name} + + + <.price_per_round cents={@cheapest_map[cal.id]} /> + + +
+
+
+
+
+ """ + end +end diff --git a/lib/ammoprices_web/router.ex b/lib/ammoprices_web/router.ex new file mode 100644 index 0000000..257d9d3 --- /dev/null +++ b/lib/ammoprices_web/router.ex @@ -0,0 +1,45 @@ +defmodule AmmopricesWeb.Router do + use AmmopricesWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {AmmopricesWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", AmmopricesWeb do + pipe_through :browser + + live "/", HomeLive + live "/calibers/:slug", CaliberLive.Show + end + + # Other scopes may use custom stacks. + # scope "/api", AmmopricesWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:ammoprices, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: AmmopricesWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/ammoprices_web/telemetry.ex b/lib/ammoprices_web/telemetry.ex new file mode 100644 index 0000000..d9546e1 --- /dev/null +++ b/lib/ammoprices_web/telemetry.ex @@ -0,0 +1,94 @@ +defmodule AmmopricesWeb.Telemetry do + @moduledoc false + use Supervisor + + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("ammoprices.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("ammoprices.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("ammoprices.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("ammoprices.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("ammoprices.repo.query.idle_time", + unit: {:native, :millisecond}, + description: "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {AmmopricesWeb, :count_users, []} + ] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..79740ef --- /dev/null +++ b/mix.exs @@ -0,0 +1,92 @@ +defmodule Ammoprices.MixProject do + use Mix.Project + + def project do + [ + app: :ammoprices, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Ammoprices.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.8.3"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.1.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1}, + {:swoosh, "~> 1.16"}, + {:floki, "~> 0.37"}, + {:oban, "~> 2.19"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + {:styler, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind ammoprices", "esbuild ammoprices"], + "assets.deploy": [ + "tailwind ammoprices --minify", + "esbuild ammoprices --minify", + "phx.digest" + ], + precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..efe7430 --- /dev/null +++ b/mix.lock @@ -0,0 +1,49 @@ +%{ + "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"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "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"}, + "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"}, + "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"}, + "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"}, + "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"}, + "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"}, + "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_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_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"}, + "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"}, + "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"}, + "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"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/migrations/20260311201609_create_retailers.exs b/priv/repo/migrations/20260311201609_create_retailers.exs new file mode 100644 index 0000000..040f978 --- /dev/null +++ b/priv/repo/migrations/20260311201609_create_retailers.exs @@ -0,0 +1,18 @@ +defmodule Ammoprices.Repo.Migrations.CreateRetailers do + use Ecto.Migration + + def change do + create table(:retailers, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :slug, :string, null: false + add :base_url, :string, null: false + add :enabled, :boolean, default: true, null: false + add :last_scraped_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:retailers, [:slug]) + end +end diff --git a/priv/repo/migrations/20260311201621_create_calibers.exs b/priv/repo/migrations/20260311201621_create_calibers.exs new file mode 100644 index 0000000..964c3c5 --- /dev/null +++ b/priv/repo/migrations/20260311201621_create_calibers.exs @@ -0,0 +1,17 @@ +defmodule Ammoprices.Repo.Migrations.CreateCalibers do + use Ecto.Migration + + def change do + create table(:calibers, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :slug, :string, null: false + add :category, :string, null: false + add :aliases, {:array, :string}, default: [] + + timestamps(type: :utc_datetime) + end + + create unique_index(:calibers, [:slug]) + end +end diff --git a/priv/repo/migrations/20260311201628_create_products.exs b/priv/repo/migrations/20260311201628_create_products.exs new file mode 100644 index 0000000..f9d598e --- /dev/null +++ b/priv/repo/migrations/20260311201628_create_products.exs @@ -0,0 +1,32 @@ +defmodule Ammoprices.Repo.Migrations.CreateProducts do + use Ecto.Migration + + def change do + create table(:products, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :retailer_id, references(:retailers, type: :binary_id, on_delete: :delete_all), + null: false + + add :caliber_id, references(:calibers, type: :binary_id, on_delete: :delete_all), + null: false + + add :title, :string, null: false + add :url, :string, null: false + add :brand, :string + add :grain_weight, :integer + add :round_count, :integer + add :casing, :string + add :condition, :string, default: "new" + add :upc, :string + add :external_id, :string + add :in_stock, :boolean, default: true, null: false + add :last_seen_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:products, [:retailer_id, :url]) + create index(:products, [:caliber_id, :in_stock]) + end +end diff --git a/priv/repo/migrations/20260311201635_create_price_snapshots.exs b/priv/repo/migrations/20260311201635_create_price_snapshots.exs new file mode 100644 index 0000000..0a701a4 --- /dev/null +++ b/priv/repo/migrations/20260311201635_create_price_snapshots.exs @@ -0,0 +1,22 @@ +defmodule Ammoprices.Repo.Migrations.CreatePriceSnapshots do + use Ecto.Migration + + def change do + create table(:price_snapshots, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :product_id, references(:products, type: :binary_id, on_delete: :delete_all), + null: false + + add :price_cents, :integer, null: false + add :price_per_round_cents, :integer, null: false + add :in_stock, :boolean, default: true, null: false + add :recorded_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:price_snapshots, [:product_id, :recorded_at]) + create index(:price_snapshots, [:recorded_at]) + end +end diff --git a/priv/repo/migrations/20260311201652_add_oban_jobs_table.exs b/priv/repo/migrations/20260311201652_add_oban_jobs_table.exs new file mode 100644 index 0000000..540de3b --- /dev/null +++ b/priv/repo/migrations/20260311201652_add_oban_jobs_table.exs @@ -0,0 +1,11 @@ +defmodule Ammoprices.Repo.Migrations.AddObanJobsTable do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 12) + end + + def down do + Oban.Migration.down(version: 1) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..f5b65a8 --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,85 @@ +alias Ammoprices.Catalog.Caliber +alias Ammoprices.Catalog.Retailer +alias Ammoprices.Repo + +# Retailers +retailers = [ + %{ + name: "Lucky Gunner", + slug: "lucky-gunner", + base_url: "https://www.luckygunner.com" + }, + %{ + name: "SGAmmo", + slug: "sgammo", + base_url: "https://www.sgammo.com" + } +] + +for attrs <- retailers do + %Retailer{} + |> Retailer.changeset(attrs) + |> Repo.insert!(on_conflict: :nothing, conflict_target: :slug) +end + +# Calibers by category +calibers = [ + # Handgun + %{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19", "9mm parabellum", "9mm nato"]}, + %{name: ".45 ACP", slug: "45-acp", category: "handgun", aliases: [".45 acp", "45 acp", ".45 auto", "45 auto"]}, + %{name: ".380 ACP", slug: "380-acp", category: "handgun", aliases: [".380", "380 acp", ".380 auto", "380 auto"]}, + %{name: ".40 S&W", slug: "40-sw", category: "handgun", aliases: [".40 s&w", "40 s&w", ".40 cal", "40 cal"]}, + %{ + name: ".38 Special", + slug: "38-special", + category: "handgun", + aliases: [".38 spl", "38 special", ".38 spc", "38 spl"] + }, + %{name: ".357 Magnum", slug: "357-magnum", category: "handgun", aliases: [".357 mag", "357 mag", "357 magnum"]}, + %{name: "10mm Auto", slug: "10mm-auto", category: "handgun", aliases: ["10mm", "10mm auto"]}, + + # Rifle + %{ + name: "5.56x45 / .223 Rem", + slug: "556-223", + category: "rifle", + aliases: ["5.56", "5.56x45", ".223", ".223 rem", "223 remington", "5.56 nato"] + }, + %{ + name: ".308 Win / 7.62x51", + slug: "308-win", + category: "rifle", + aliases: [".308", ".308 win", "308 winchester", "7.62x51", "7.62 nato"] + }, + %{name: "7.62x39", slug: "762x39", category: "rifle", aliases: ["7.62x39", "7.62x39mm"]}, + %{ + name: ".30-06 Springfield", + slug: "30-06", + category: "rifle", + aliases: [".30-06", "30-06", "30-06 springfield", ".30-06 sprg"] + }, + %{ + name: ".300 Blackout", + slug: "300-blackout", + category: "rifle", + aliases: [".300 blk", "300 blackout", ".300 aac blackout", "300 blk"] + }, + %{name: "6.5 Creedmoor", slug: "65-creedmoor", category: "rifle", aliases: ["6.5 creedmoor", "6.5cm", "6.5 cm"]}, + + # Rimfire + %{name: ".22 LR", slug: "22-lr", category: "rimfire", aliases: [".22 lr", "22 lr", ".22 long rifle", "22 long rifle"]}, + %{name: ".22 WMR", slug: "22-wmr", category: "rimfire", aliases: [".22 wmr", "22 wmr", ".22 magnum", "22 mag"]}, + %{name: ".17 HMR", slug: "17-hmr", category: "rimfire", aliases: [".17 hmr", "17 hmr", ".17 hornady magnum"]}, + + # Shotgun + %{name: "12 Gauge", slug: "12-gauge", category: "shotgun", aliases: ["12 gauge", "12 ga", "12ga"]}, + %{name: "20 Gauge", slug: "20-gauge", category: "shotgun", aliases: ["20 gauge", "20 ga", "20ga"]} +] + +for attrs <- calibers do + %Caliber{} + |> Caliber.changeset(attrs) + |> Repo.insert!(on_conflict: :nothing, conflict_target: :slug) +end + +IO.puts("Seeded #{length(retailers)} retailers and #{length(calibers)} calibers") diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon.ico differ diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/ammoprices/catalog_test.exs b/test/ammoprices/catalog_test.exs new file mode 100644 index 0000000..0578043 --- /dev/null +++ b/test/ammoprices/catalog_test.exs @@ -0,0 +1,155 @@ +defmodule Ammoprices.CatalogTest do + use Ammoprices.DataCase, async: true + + import Ammoprices.Fixtures + + alias Ammoprices.Catalog + + describe "retailers" do + test "list_retailers/0 returns all retailers" do + retailer = retailer_fixture() + assert Catalog.list_retailers() == [retailer] + 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: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://luckygunner.com"} + assert {:ok, retailer} = Catalog.create_retailer(attrs) + assert retailer.name == "Lucky Gunner" + assert retailer.slug == "lucky-gunner" + assert retailer.base_url == "https://luckygunner.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 Catalog.list_calibers() == [caliber] + 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 result == [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: ".45 ACP", + slug: "45-acp", + category: "handgun", + aliases: [".45", "45 auto"] + } + + assert {:ok, caliber} = Catalog.create_caliber(attrs) + assert caliber.name == ".45 ACP" + assert caliber.aliases == [".45", "45 auto"] + 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 "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 +end diff --git a/test/ammoprices/prices_test.exs b/test/ammoprices/prices_test.exs new file mode 100644 index 0000000..3337551 --- /dev/null +++ b/test/ammoprices/prices_test.exs @@ -0,0 +1,230 @@ +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 diff --git a/test/ammoprices/scraping/caliber_matcher_test.exs b/test/ammoprices/scraping/caliber_matcher_test.exs new file mode 100644 index 0000000..ede470a --- /dev/null +++ b/test/ammoprices/scraping/caliber_matcher_test.exs @@ -0,0 +1,37 @@ +defmodule Ammoprices.Scraping.CaliberMatcherTest do + use Ammoprices.DataCase, async: true + + import Ammoprices.Fixtures + + alias Ammoprices.Scraping.CaliberMatcher + + describe "match/2" do + test "matches by caliber name" do + caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]}) + calibers = [caliber] + + assert CaliberMatcher.match("Federal 9mm Luger 115gr FMJ", calibers) == caliber + end + + test "matches by alias" do + caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]}) + calibers = [caliber] + + assert CaliberMatcher.match("Wolf 9x19 124gr FMJ", calibers) == caliber + end + + test "returns nil when no match" do + caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]}) + calibers = [caliber] + + assert CaliberMatcher.match("Some .45 ACP Ammo", calibers) == nil + end + + test "match is case insensitive" do + caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", aliases: ["9mm", "9x19"]}) + calibers = [caliber] + + assert CaliberMatcher.match("WOLF 9MM LUGER 115GR", calibers) == caliber + end + end +end diff --git a/test/ammoprices/scraping/retailers/lucky_gunner_test.exs b/test/ammoprices/scraping/retailers/lucky_gunner_test.exs new file mode 100644 index 0000000..265105b --- /dev/null +++ b/test/ammoprices/scraping/retailers/lucky_gunner_test.exs @@ -0,0 +1,92 @@ +defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do + use ExUnit.Case, async: true + + alias Ammoprices.Scraping.Retailers.LuckyGunner + + @fixture_path "test/fixtures/lucky_gunner/9mm.html" + + describe "retailer_slug/0" do + test "returns lucky-gunner" do + assert LuckyGunner.retailer_slug() == "lucky-gunner" + end + end + + describe "category_url/1" do + test "maps 9mm-luger caliber to correct URL" do + caliber = %{slug: "9mm-luger", category: "handgun"} + assert LuckyGunner.category_url(caliber) == "/handgun/9mm-ammo" + end + + test "returns nil for unmapped caliber" do + caliber = %{slug: "unknown-caliber", category: "handgun"} + assert LuckyGunner.category_url(caliber) == nil + end + end + + describe "parse_products/1" do + setup do + html = File.read!(@fixture_path) + %{html: html} + end + + test "extracts all products from HTML", %{html: html} do + products = LuckyGunner.parse_products(html) + assert length(products) == 3 + end + + test "extracts product title", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.title == "9mm - 115 Grain FMJ - Wolf - 1350 Rounds **STEEL CASES**" + end + + test "extracts product URL", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.url == "https://www.luckygunner.com/9mm-115-grain-fmj-wolf-1350-rounds" + end + + test "extracts price in cents", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.price_cents == 27_000 + end + + test "extracts price per round in cents", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.price_per_round_cents == 20 + end + + test "extracts fractional price per round", %{html: html} do + products = LuckyGunner.parse_products(html) + second = Enum.at(products, 1) + # 21.2¢ → 21 cents (truncated to integer) + assert second.price_per_round_cents == 21 + end + + test "extracts brand from description", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.brand == "Wolf" + end + + test "extracts grain weight from description", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.grain_weight == 115 + end + + test "extracts round count from title", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.round_count == 1350 + end + + test "detects casing material from description", %{html: html} do + products = LuckyGunner.parse_products(html) + # First product: steel casings + assert hd(products).casing == "steel" + # Third product: brass casings + assert Enum.at(products, 2).casing == "brass" + end + + test "marks products as in stock", %{html: html} do + [first | _] = LuckyGunner.parse_products(html) + assert first.in_stock == true + end + end +end diff --git a/test/ammoprices/scraping/retailers/sg_ammo_test.exs b/test/ammoprices/scraping/retailers/sg_ammo_test.exs new file mode 100644 index 0000000..d5bda7d --- /dev/null +++ b/test/ammoprices/scraping/retailers/sg_ammo_test.exs @@ -0,0 +1,77 @@ +defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do + use ExUnit.Case, async: true + + alias Ammoprices.Scraping.Retailers.SgAmmo + + @fixture_path "test/fixtures/sg_ammo/9mm.html" + + describe "retailer_slug/0" do + test "returns sgammo" do + assert SgAmmo.retailer_slug() == "sgammo" + end + end + + describe "category_url/1" do + test "maps 9mm-luger caliber to correct URL" do + caliber = %{slug: "9mm-luger", category: "handgun"} + assert SgAmmo.category_url(caliber) == "/catalog/pistol-ammo-for-sale/9mm-luger-ammo" + end + + test "returns nil for unmapped caliber" do + caliber = %{slug: "unknown", category: "handgun"} + assert SgAmmo.category_url(caliber) == nil + end + end + + describe "parse_products/1" do + setup do + html = File.read!(@fixture_path) + %{html: html} + end + + test "extracts all products from HTML", %{html: html} do + products = SgAmmo.parse_products(html) + assert length(products) == 3 + end + + test "extracts product title", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.title =~ "50 Round Box - 9mm Luger 115 Grain FMJ Ammo by Magtech" + end + + test "extracts product URL", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.url =~ "sgammo.com/product/9mm-luger-ammo/" + end + + test "extracts price in cents", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.price_cents == 1395 + end + + test "extracts price per round in cents", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.price_per_round_cents == 28 + end + + test "extracts round count from title", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.round_count == 50 + end + + test "extracts grain weight from title", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.grain_weight == 115 + end + + test "extracts brand from title", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.brand == "Magtech" + end + + test "marks in-stock products", %{html: html} do + [first | _] = SgAmmo.parse_products(html) + assert first.in_stock == true + end + end +end diff --git a/test/ammoprices/scraping/runner_test.exs b/test/ammoprices/scraping/runner_test.exs new file mode 100644 index 0000000..7e882be --- /dev/null +++ b/test/ammoprices/scraping/runner_test.exs @@ -0,0 +1,52 @@ +defmodule Ammoprices.Scraping.RunnerTest do + use Ammoprices.DataCase, async: true + + import Ammoprices.Fixtures + + alias Ammoprices.Catalog + 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(Ammoprices.Scraping.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 == 3 + assert stats.snapshots_count == 3 + + products = Catalog.list_products_for_caliber(caliber.id) + assert length(products) == 3 + 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 + end +end diff --git a/test/ammoprices/scraping/scrape_job_test.exs b/test/ammoprices/scraping/scrape_job_test.exs new file mode 100644 index 0000000..c32a2e4 --- /dev/null +++ b/test/ammoprices/scraping/scrape_job_test.exs @@ -0,0 +1,28 @@ +defmodule Ammoprices.Scraping.ScrapeJobTest do + use Ammoprices.DataCase + use Oban.Testing, repo: Ammoprices.Repo + + import Ammoprices.Fixtures + + alias Ammoprices.Scraping.ScrapeJob + + @fixture_html File.read!("test/fixtures/lucky_gunner/9mm.html") + + setup do + retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"}) + retailer_fixture(%{name: "SGAmmo", slug: "sgammo", base_url: "https://www.sgammo.com"}) + caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]}) + + Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn -> + Req.Test.html(conn, @fixture_html) + end) + + :ok + end + + describe "perform/1" do + test "executes scrape for all enabled scrapers and calibers" do + assert :ok = perform_job(ScrapeJob, %{}) + end + end +end diff --git a/test/ammoprices_web/controllers/error_html_test.exs b/test/ammoprices_web/controllers/error_html_test.exs new file mode 100644 index 0000000..1af0144 --- /dev/null +++ b/test/ammoprices_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule AmmopricesWeb.ErrorHTMLTest do + use AmmopricesWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template, only: [render_to_string: 4] + + test "renders 404.html" do + assert render_to_string(AmmopricesWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(AmmopricesWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/ammoprices_web/controllers/error_json_test.exs b/test/ammoprices_web/controllers/error_json_test.exs new file mode 100644 index 0000000..9347780 --- /dev/null +++ b/test/ammoprices_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule AmmopricesWeb.ErrorJSONTest do + use AmmopricesWeb.ConnCase, async: true + + test "renders 404" do + assert AmmopricesWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert AmmopricesWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/ammoprices_web/controllers/page_controller_test.exs b/test/ammoprices_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..4f330c6 --- /dev/null +++ b/test/ammoprices_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule AmmopricesWeb.PageControllerTest do + use AmmopricesWeb.ConnCase + + test "GET / redirects to HomeLive", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Ammo Price Tracker" + end +end diff --git a/test/ammoprices_web/live/caliber_live/show_test.exs b/test/ammoprices_web/live/caliber_live/show_test.exs new file mode 100644 index 0000000..e0892f3 --- /dev/null +++ b/test/ammoprices_web/live/caliber_live/show_test.exs @@ -0,0 +1,93 @@ +defmodule AmmopricesWeb.CaliberLive.ShowTest do + use AmmopricesWeb.ConnCase + + import Ammoprices.Fixtures + import Phoenix.LiveViewTest + + setup do + caliber = caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"}) + retailer = retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"}) + + %{caliber: caliber, retailer: retailer} + end + + describe "CaliberLive.Show" do + test "renders caliber page with title", %{conn: conn, caliber: caliber} do + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#caliber-heading") + end + + test "renders product listings with stream", %{conn: conn, caliber: caliber, retailer: retailer} do + product = product_fixture(%{caliber: caliber, retailer: retailer, title: "Federal 9mm 115gr"}) + 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}") + + assert has_element?(view, "#products") + assert has_element?(view, "#products [id]") + end + + test "shows empty state when no products", %{conn: conn, caliber: caliber} do + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#products-empty") + end + + test "filters by in-stock", %{conn: conn, caliber: caliber, retailer: retailer} do + in_stock = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, title: "In Stock Product"}) + oos = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: false, title: "Out of Stock Product"}) + now = DateTime.truncate(DateTime.utc_now(), :second) + snapshot_fixture(%{product: in_stock, price_per_round_cents: 28, in_stock: true, recorded_at: now}) + snapshot_fixture(%{product: oos, price_per_round_cents: 22, in_stock: false, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + # Default is in_stock: true + assert has_element?(view, "#filter-in-stock") + end + + test "renders price stats banner", %{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: 25, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#price-stats") + end + + test "renders chart container with data attribute", %{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: 30, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#price-chart-container") + assert has_element?(view, "[data-chart-data]") + end + + test "renders time range buttons", %{conn: conn, caliber: caliber} do + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert has_element?(view, "#range-7d") + assert has_element?(view, "#range-30d") + assert has_element?(view, "#range-90d") + assert has_element?(view, "#range-1y") + assert has_element?(view, "#range-all") + end + + test "changes chart range when range button clicked", %{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: 30, in_stock: true, recorded_at: now}) + + {:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}") + + assert view |> element("#range-7d") |> render_click() + assert has_element?(view, "#price-chart-container") + end + end +end diff --git a/test/ammoprices_web/live/home_live_test.exs b/test/ammoprices_web/live/home_live_test.exs new file mode 100644 index 0000000..5771375 --- /dev/null +++ b/test/ammoprices_web/live/home_live_test.exs @@ -0,0 +1,37 @@ +defmodule AmmopricesWeb.HomeLiveTest do + use AmmopricesWeb.ConnCase, async: true + + import Ammoprices.Fixtures + import Phoenix.LiveViewTest + + describe "HomeLive" do + test "renders category sections", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + assert has_element?(view, "#category-handgun") + assert has_element?(view, "#category-rifle") + assert has_element?(view, "#category-rimfire") + assert has_element?(view, "#category-shotgun") + end + + test "renders caliber links", %{conn: conn} do + caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun"}) + + {:ok, view, _html} = live(conn, "/") + + assert has_element?(view, "#caliber-9mm-luger") + end + + test "shows cheapest price when snapshots exist", %{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) + snapshot_fixture(%{product: product, price_per_round_cents: 25, in_stock: true, recorded_at: now}) + + {:ok, _view, html} = live(conn, "/") + + assert html =~ "25" + end + end +end diff --git a/test/fixtures/lucky_gunner/9mm.html b/test/fixtures/lucky_gunner/9mm.html new file mode 100644 index 0000000..17875bb --- /dev/null +++ b/test/fixtures/lucky_gunner/9mm.html @@ -0,0 +1,103 @@ + diff --git a/test/fixtures/sg_ammo/9mm.html b/test/fixtures/sg_ammo/9mm.html new file mode 100644 index 0000000..aeb7065 --- /dev/null +++ b/test/fixtures/sg_ammo/9mm.html @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

+ + 50 Round Box - 9mm Luger 115 Grain FMJ Ammo by Magtech - 9A + +

+
SKU: MGT-9A-Box
+
+ 120+ + + + $13.95 Each + + 20+ @$12.99 + ($0.28 Per Round) +
+ + +

+ + 1000 Round Case - 9mm Luger 115 Grain FMJ Ammo by Magtech - 9A + +

+
SKU: MTG-9A-case
+
+ 25+ + + + $259.80 Each + + FREE
SHIPPING
+ 2+ @$249.80 + ($0.26 Per Round) +
+ + +

+ + 100 Round Box - 9mm Luger Winchester FMJ 115 Grain Value Pack Ammo - USA9MMVP + +

+
SKU: USA9MMVP
+
+ 50+ + + + $27.95 Each + + 10+ @$25.95 + ($0.28 Per Round) +
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..88c8b26 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule AmmopricesWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use AmmopricesWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + use AmmopricesWeb, :verified_routes + + import AmmopricesWeb.ConnCase + import Phoenix.ConnTest + import Plug.Conn + # The default endpoint for testing + @endpoint AmmopricesWeb.Endpoint + + # Import conveniences for testing with connections + end + end + + setup tags do + Ammoprices.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..f554911 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,60 @@ +defmodule Ammoprices.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Ammoprices.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + + using do + quote do + import Ammoprices.DataCase + import Ecto + import Ecto.Changeset + import Ecto.Query + + alias Ammoprices.Repo + end + end + + setup tags do + Ammoprices.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Sandbox.start_owner!(Ammoprices.Repo, shared: not tags[:async]) + on_exit(fn -> Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex new file mode 100644 index 0000000..bf3e8c2 --- /dev/null +++ b/test/support/fixtures.ex @@ -0,0 +1,96 @@ +defmodule Ammoprices.Fixtures do + @moduledoc """ + Factory functions for test data. + """ + + alias Ammoprices.Catalog.Caliber + alias Ammoprices.Catalog.Product + alias Ammoprices.Catalog.Retailer + alias Ammoprices.Prices.PriceSnapshot + alias Ammoprices.Repo + + def retailer_fixture(attrs \\ %{}) do + {:ok, retailer} = + %Retailer{} + |> Retailer.changeset( + Map.merge( + %{ + name: "Test Retailer", + slug: "test-retailer-#{System.unique_integer([:positive])}", + base_url: "https://example.com" + }, + attrs + ) + ) + |> Repo.insert() + + retailer + end + + def caliber_fixture(attrs \\ %{}) do + {:ok, caliber} = + %Caliber{} + |> Caliber.changeset( + Map.merge( + %{ + name: "9mm Luger", + slug: "9mm-luger-#{System.unique_integer([:positive])}", + category: "handgun", + aliases: ["9mm", "9x19"] + }, + attrs + ) + ) + |> Repo.insert() + + caliber + end + + def product_fixture(attrs \\ %{}) do + retailer = Map.get_lazy(attrs, :retailer, fn -> retailer_fixture() end) + caliber = Map.get_lazy(attrs, :caliber, fn -> caliber_fixture() end) + + {:ok, product} = + %Product{retailer_id: retailer.id, caliber_id: caliber.id} + |> Product.changeset( + Map.merge( + %{ + title: "Test Ammo 9mm 115gr FMJ", + url: "/products/test-#{System.unique_integer([:positive])}", + brand: "TestBrand", + grain_weight: 115, + round_count: 50, + casing: "brass", + condition: "new", + in_stock: true, + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + }, + Map.drop(attrs, [:retailer, :caliber]) + ) + ) + |> Repo.insert() + + product + end + + def snapshot_fixture(attrs \\ %{}) do + product = Map.get_lazy(attrs, :product, fn -> product_fixture() end) + + {:ok, snapshot} = + %PriceSnapshot{product_id: product.id} + |> PriceSnapshot.changeset( + Map.merge( + %{ + price_cents: 1599, + price_per_round_cents: 32, + in_stock: true, + recorded_at: DateTime.truncate(DateTime.utc_now(), :second) + }, + Map.delete(attrs, :product) + ) + ) + |> Repo.insert() + + snapshot + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..5eb15fe --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Ammoprices.Repo, :manual)