Added auth. Put together basic pricing and order API calls.

This commit is contained in:
2026-01-24 19:13:18 -05:00
parent f08b3d1817
commit 0a8db9c21d
14 changed files with 844 additions and 7 deletions

View File

@@ -11,6 +11,10 @@ import Config
config :traderex, :tradier_api_key, System.get_env("TRADIER_API_KEY")
config :traderex, :tradier_account, System.get_env("TRADIER_ACCOUNT")
# Dashboard authentication
config :traderex, :dashboard_user, System.get_env("DASHBOARD_USER")
config :traderex, :dashboard_password, System.get_env("DASHBOARD_PASSWORD")
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server

View File

@@ -0,0 +1,89 @@
defmodule Core.BalanceHistory do
@moduledoc """
Represents historical balance data for a Tradier account.
"""
defmodule Snapshot do
@moduledoc """
Represents a single balance snapshot at a point in time.
"""
@type t :: %__MODULE__{
date: Date.t() | nil,
value: float()
}
defstruct [:date, :value]
@spec from_api(map()) :: t()
def from_api(data) when is_map(data) do
%__MODULE__{
date: parse_date(data["date"]),
value: to_float(data["value"])
}
end
defp parse_date(nil), do: nil
defp parse_date(date_string) when is_binary(date_string) do
case Date.from_iso8601(date_string) do
{:ok, date} -> date
{:error, _reason} -> nil
end
end
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value / 1
defp to_float(_), do: 0.0
end
@type t :: %__MODULE__{
balances: [Snapshot.t()],
delta: float(),
delta_percent: float()
}
defstruct [
:balances,
:delta,
:delta_percent
]
@doc """
Creates a BalanceHistory struct from a Tradier API historical-balances response.
## Example
iex> data = %{"historical_balances" => %{"balances" => %{"balance" => [...]}, "delta" => 100.0, "delta_percent" => 1.5}}
iex> Core.BalanceHistory.from_api(data)
%Core.BalanceHistory{balances: [...], delta: 100.0, delta_percent: 1.5}
"""
@spec from_api(map()) :: t()
def from_api(%{"historical_balances" => data}) do
from_api(data)
end
def from_api(data) when is_map(data) do
balances =
case data do
%{"balances" => %{"balance" => list}} when is_list(list) ->
Enum.map(list, &Snapshot.from_api/1)
%{"balances" => %{"balance" => single}} when is_map(single) ->
[Snapshot.from_api(single)]
_ ->
[]
end
%__MODULE__{
balances: balances,
delta: to_float(data["delta"]),
delta_percent: to_float(data["delta_percent"])
}
end
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value / 1
defp to_float(_), do: 0.0
end

View File

@@ -66,6 +66,144 @@ defmodule Core.Client do
end
end
@doc """
Fetches account history events and returns a list of Event structs.
## Example
{:ok, events} = Core.Client.get_history("6YA15850")
#=> [%Core.Event{type: "trade", symbol: "AAPL", ...}]
"""
@spec get_history(String.t()) :: {:ok, [Core.Event.t()]} | {:error, term()}
def get_history(account_id) do
case get("/accounts/#{account_id}/history") do
{:ok, body} -> {:ok, Core.Event.list_from_api(body)}
{:error, reason} -> {:error, reason}
end
end
@balance_history_periods ~w(WEEK MONTH YTD YEAR YEAR_3 YEAR_5 ALL)
@doc """
Returns the valid period values for balance history.
"""
def balance_history_periods, do: @balance_history_periods
@doc """
Fetches historical balance data for an account.
## Options
* `:period` - Time period for history. One of: `WEEK`, `MONTH`, `YTD`, `YEAR`, `YEAR_3`, `YEAR_5`, `ALL`.
Defaults to `MONTH`.
## Example
{:ok, history} = Core.Client.get_balance_history("6YA15850", period: "YEAR")
history.delta
#=> 2780.66
history.balances
#=> [%Core.BalanceHistory.Snapshot{date: ~D[2025-07-15], value: 113302.4}, ...]
"""
@spec get_balance_history(String.t(), keyword()) ::
{:ok, Core.BalanceHistory.t()} | {:error, term()}
def get_balance_history(account_id, opts \\ []) do
period = Keyword.get(opts, :period, "MONTH")
query = URI.encode_query(%{period: period})
case get("/accounts/#{account_id}/historical-balances?#{query}") do
{:ok, body} -> {:ok, Core.BalanceHistory.from_api(body)}
{:error, reason} -> {:error, reason}
end
end
@doc """
Fetches stock quotes for one or more symbols.
## Examples
{:ok, quotes} = Core.Client.get_quotes("SPY")
#=> [%Core.Quote{symbol: "SPY", last: 273.47, ...}]
{:ok, quotes} = Core.Client.get_quotes(["SPY", "AAPL"])
#=> [%Core.Quote{symbol: "SPY", ...}, %Core.Quote{symbol: "AAPL", ...}]
"""
@spec get_quotes(String.t() | [String.t()]) :: {:ok, [Core.Quote.t()]} | {:error, term()}
def get_quotes(symbols) when is_list(symbols) do
symbols_str = Enum.join(symbols, ",")
get_quotes(symbols_str)
end
def get_quotes(symbol) when is_binary(symbol) do
query = URI.encode_query(%{symbols: symbol})
case get("/markets/quotes?#{query}") do
{:ok, body} -> {:ok, Core.Quote.list_from_api(body)}
{:error, reason} -> {:error, reason}
end
end
@doc """
Places an order for an account.
## Options
* `:symbol` - The stock symbol (required)
* `:quantity` - Number of shares (required)
* `:side` - Order side: "buy" or "sell" (required)
* `:type` - Order type: "market", "limit", "stop", "stop_limit" (required)
* `:price` - Limit price (required for limit orders)
* `:stop` - Stop price (required for stop orders)
* `:duration` - Order duration: "day", "gtc", "pre", "post" (default: "day")
* `:class` - Order class: "equity", "option", "multileg", "combo" (default: "equity")
* `:preview` - If true, validates order without submitting (default: true)
## Examples
# Preview an order
{:ok, order} = Core.Client.place_order("6YA15850",
symbol: "SPY",
quantity: 10,
side: "buy",
type: "limit",
price: 500,
preview: true
)
# Submit the order
{:ok, order} = Core.Client.place_order("6YA15850",
symbol: "SPY",
quantity: 10,
side: "buy",
type: "limit",
price: 500,
preview: false
)
"""
@spec place_order(String.t(), keyword()) :: {:ok, Core.Order.t()} | {:error, term()}
def place_order(account_id, opts) do
params =
%{
class: Keyword.get(opts, :class, "equity"),
symbol: Keyword.fetch!(opts, :symbol),
quantity: Keyword.fetch!(opts, :quantity),
side: Keyword.fetch!(opts, :side),
type: Keyword.fetch!(opts, :type),
duration: Keyword.get(opts, :duration, "day"),
preview: Keyword.get(opts, :preview, true)
}
|> maybe_put(:price, Keyword.get(opts, :price))
|> maybe_put(:stop, Keyword.get(opts, :stop))
case post("/accounts/#{account_id}/orders", params) do
{:ok, body} -> {:ok, Core.Order.from_api(body)}
{:error, reason} -> {:error, reason}
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp get(path) do
url = @base_url <> path
@@ -81,6 +219,21 @@ defmodule Core.Client do
end
end
defp post(path, params) do
url = @base_url <> path
case Req.post(url, headers: headers(), form: params) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, status, body}}
{:error, reason} ->
{:error, reason}
end
end
defp headers do
[
accept: "application/json",

View File

@@ -0,0 +1,74 @@
defmodule Core.Event do
@moduledoc """
Represents an account history event from Tradier.
"""
@type t :: %__MODULE__{
date: DateTime.t() | nil,
type: String.t(),
symbol: String.t() | nil,
quantity: float() | nil,
price: float() | nil,
amount: float(),
description: String.t(),
commission: float()
}
defstruct [
:date,
:type,
:symbol,
:quantity,
:price,
:amount,
:description,
:commission
]
@doc """
Creates an Event struct from a Tradier API event response map.
"""
@spec from_api(map()) :: t()
def from_api(data) when is_map(data) do
%__MODULE__{
date: parse_datetime(data["date"]),
type: data["type"],
symbol: data["symbol"],
quantity: data["quantity"],
price: data["price"],
amount: data["amount"],
description: data["description"],
commission: data["commission"]
}
end
@doc """
Parses events from a Tradier API history response.
The API returns events wrapped in a "history" key, and the "event"
value can be either a single map (one event) or a list (multiple events).
Returns an empty list if there are no events.
"""
@spec list_from_api(map()) :: [t()]
def list_from_api(%{"history" => %{"event" => events}}) when is_list(events) do
Enum.map(events, &from_api/1)
end
def list_from_api(%{"history" => %{"event" => event}}) when is_map(event) do
[from_api(event)]
end
def list_from_api(%{"history" => "null"}), do: []
def list_from_api(%{"history" => nil}), do: []
def list_from_api(_), do: []
defp parse_datetime(nil), do: nil
defp parse_datetime(datetime_string) when is_binary(datetime_string) do
case DateTime.from_iso8601(datetime_string) do
{:ok, datetime, _offset} -> datetime
{:error, _reason} -> nil
end
end
end

View File

@@ -0,0 +1,39 @@
defmodule Core.Order do
@moduledoc """
Represents an order response from Tradier.
"""
@type t :: %__MODULE__{
id: integer() | nil,
status: String.t() | nil,
partner_id: String.t() | nil
}
defstruct [
:id,
:status,
:partner_id
]
@doc """
Creates an Order struct from a Tradier API order response.
## Example
iex> data = %{"order" => %{"id" => 123456, "status" => "ok", "partner_id" => "partner_12345"}}
iex> Core.Order.from_api(data)
%Core.Order{id: 123456, status: "ok", partner_id: "partner_12345"}
"""
@spec from_api(map()) :: t()
def from_api(%{"order" => order}) when is_map(order) do
from_api(order)
end
def from_api(data) when is_map(data) do
%__MODULE__{
id: data["id"],
status: data["status"],
partner_id: data["partner_id"]
}
end
end

View File

@@ -0,0 +1,99 @@
defmodule Core.Quote do
@moduledoc """
Represents a stock quote from Tradier.
"""
@type t :: %__MODULE__{
symbol: String.t(),
description: String.t() | nil,
exch: String.t() | nil,
type: String.t() | nil,
last: float() | nil,
change: float() | nil,
change_percentage: float() | nil,
volume: integer() | nil,
open: float() | nil,
high: float() | nil,
low: float() | nil,
close: float() | nil,
bid: float() | nil,
ask: float() | nil,
prevclose: float() | nil,
week_52_high: float() | nil,
week_52_low: float() | nil,
average_volume: integer() | nil
}
defstruct [
:symbol,
:description,
:exch,
:type,
:last,
:change,
:change_percentage,
:volume,
:open,
:high,
:low,
:close,
:bid,
:ask,
:prevclose,
:week_52_high,
:week_52_low,
:average_volume
]
@doc """
Creates a Quote struct from a Tradier API quote response map.
"""
@spec from_api(map()) :: t()
def from_api(data) when is_map(data) do
%__MODULE__{
symbol: data["symbol"],
description: data["description"],
exch: data["exch"],
type: data["type"],
last: to_float(data["last"]),
change: to_float(data["change"]),
change_percentage: to_float(data["change_percentage"]),
volume: data["volume"],
open: to_float(data["open"]),
high: to_float(data["high"]),
low: to_float(data["low"]),
close: to_float(data["close"]),
bid: to_float(data["bid"]),
ask: to_float(data["ask"]),
prevclose: to_float(data["prevclose"]),
week_52_high: to_float(data["week_52_high"]),
week_52_low: to_float(data["week_52_low"]),
average_volume: data["average_volume"]
}
end
@doc """
Parses quotes from a Tradier API quotes response.
The API returns quotes wrapped in a "quotes" key, and the "quote"
value can be either a single map (one symbol) or a list (multiple symbols).
Returns an empty list if there are no quotes.
"""
@spec list_from_api(map()) :: [t()]
def list_from_api(%{"quotes" => %{"quote" => quotes}}) when is_list(quotes) do
Enum.map(quotes, &from_api/1)
end
def list_from_api(%{"quotes" => %{"quote" => quote}}) when is_map(quote) do
[from_api(quote)]
end
def list_from_api(%{"quotes" => "null"}), do: []
def list_from_api(%{"quotes" => nil}), do: []
def list_from_api(_), do: []
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value / 1
defp to_float(_), do: nil
end

View File

@@ -0,0 +1,48 @@
defmodule TraderexWeb.Auth do
@moduledoc """
Simple authentication module using environment variables.
"""
import Plug.Conn
@doc """
Verifies the given credentials against the configured environment variables.
"""
def verify_credentials(username, password) do
configured_user = Application.get_env(:traderex, :dashboard_user)
configured_password = Application.get_env(:traderex, :dashboard_password)
if configured_user && configured_password &&
Plug.Crypto.secure_compare(username, configured_user) &&
Plug.Crypto.secure_compare(password, configured_password) do
:ok
else
:error
end
end
@doc """
Logs in the user by setting the session.
"""
def login(conn) do
conn
|> put_session(:authenticated, true)
|> configure_session(renew: true)
end
@doc """
Logs out the user by clearing the session.
"""
def logout(conn) do
conn
|> clear_session()
|> configure_session(drop: true)
end
@doc """
Checks if the user is authenticated.
"""
def authenticated?(conn) do
get_session(conn, :authenticated) == true
end
end

View File

@@ -0,0 +1,23 @@
defmodule TraderexWeb.AuthController do
use TraderexWeb, :controller
def login(conn, %{"credentials" => %{"username" => username, "password" => password}}) do
case TraderexWeb.Auth.verify_credentials(username, password) do
:ok ->
conn
|> TraderexWeb.Auth.login()
|> redirect(to: ~p"/")
:error ->
conn
|> put_flash(:error, "Invalid username or password")
|> redirect(to: ~p"/login")
end
end
def logout(conn, _params) do
conn
|> TraderexWeb.Auth.logout()
|> redirect(to: ~p"/login")
end
end

View File

@@ -9,6 +9,11 @@ defmodule TraderexWeb.DashboardLive do
socket
|> assign(:account, nil)
|> assign(:positions, [])
|> assign(:events, [])
|> assign(:balance_history, nil)
|> assign(:balance_period, "MONTH")
|> assign(:balance_history_expanded, false)
|> assign(:events_expanded, false)
|> assign(:loading, true)
|> assign(:error, nil)
@@ -22,25 +27,45 @@ defmodule TraderexWeb.DashboardLive do
@impl true
def handle_info(:load_data, socket) do
account_id = Core.Client.account_id()
period = socket.assigns.balance_period
results = {
Core.Client.get_account(account_id),
Core.Client.get_positions(account_id),
Core.Client.get_history(account_id),
Core.Client.get_balance_history(account_id, period: period)
}
socket =
case {Core.Client.get_account(account_id), Core.Client.get_positions(account_id)} do
{{:ok, account}, {:ok, positions}} ->
case results do
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
socket
|> assign(:account, account)
|> assign(:positions, positions)
|> assign(:events, Enum.take(events, 5))
|> assign(:balance_history, balance_history)
|> assign(:loading, false)
|> assign(:error, nil)
{{:error, reason}, _} ->
{{:error, reason}, _, _, _} ->
socket
|> assign(:loading, false)
|> assign(:error, "Failed to load account: #{inspect(reason)}")
{_, {:error, reason}} ->
{_, {:error, reason}, _, _} ->
socket
|> assign(:loading, false)
|> assign(:error, "Failed to load positions: #{inspect(reason)}")
{_, _, {:error, reason}, _} ->
socket
|> assign(:loading, false)
|> assign(:error, "Failed to load history: #{inspect(reason)}")
{_, _, _, {:error, reason}} ->
socket
|> assign(:loading, false)
|> assign(:error, "Failed to load balance history: #{inspect(reason)}")
end
schedule_refresh()
@@ -51,6 +76,28 @@ defmodule TraderexWeb.DashboardLive do
Process.send_after(self(), :load_data, @refresh_interval)
end
@impl true
def handle_event("change_period", %{"period" => period}, socket) do
socket =
socket
|> assign(:balance_period, period)
|> assign(:balance_history, nil)
send(self(), :load_data)
{:noreply, socket}
end
@impl true
def handle_event("toggle_balance_history", _, socket) do
{:noreply,
assign(socket, :balance_history_expanded, !socket.assigns.balance_history_expanded)}
end
@impl true
def handle_event("toggle_events", _, socket) do
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
end
@impl true
def render(assigns) do
~H"""
@@ -58,6 +105,14 @@ defmodule TraderexWeb.DashboardLive do
<.header>
Traderex Dashboard
<:subtitle>Account Overview</:subtitle>
<:actions>
<div class="flex items-center gap-4">
<TraderexWeb.Layouts.theme_toggle />
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
</.link>
</div>
</:actions>
</.header>
<div :if={@loading} class="flex justify-center items-center py-12">
@@ -112,6 +167,71 @@ defmodule TraderexWeb.DashboardLive do
/>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center flex-wrap gap-2">
<h2 class="card-title">Balance History</h2>
<div class="join">
<button
:for={period <- Core.Client.balance_history_periods()}
class={[
"join-item btn btn-sm",
@balance_period == period && "btn-active"
]}
phx-click="change_period"
phx-value-period={period}
>
{format_period_label(period)}
</button>
</div>
</div>
<div :if={@balance_history == nil} class="flex justify-center py-4">
<span class="loading loading-spinner"></span>
</div>
<div :if={@balance_history} class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="stat bg-base-100 rounded-box">
<div class="stat-title">{format_period_label(@balance_period)} Change</div>
<div class={["stat-value text-lg", pl_color(@balance_history.delta)]}>
{format_currency(@balance_history.delta)}
</div>
</div>
<div class="stat bg-base-100 rounded-box">
<div class="stat-title">{format_period_label(@balance_period)} Change %</div>
<div class={["stat-value text-lg", pl_color(@balance_history.delta_percent)]}>
{format_percent(@balance_history.delta_percent)}
</div>
</div>
</div>
<div :if={@balance_history && @balance_history.balances != []} class="mt-2">
<button
class="btn btn-ghost btn-sm gap-1"
phx-click="toggle_balance_history"
>
<.icon
name={
if @balance_history_expanded, do: "hero-chevron-up", else: "hero-chevron-down"
}
class="size-4"
/>
{if @balance_history_expanded, do: "Hide", else: "Show"} History
</button>
<.table
:if={@balance_history_expanded}
id="balance-history"
rows={Enum.take(@balance_history.balances, -10) |> Enum.reverse()}
row_id={&"balance-#{&1.date}"}
>
<:col :let={snapshot} label="Date">
{format_date_only(snapshot.date)}
</:col>
<:col :let={snapshot} label="Value">
{format_currency(snapshot.value)}
</:col>
</.table>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title">Positions</h2>
@@ -142,6 +262,51 @@ defmodule TraderexWeb.DashboardLive do
</.table>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">Recent Activity</h2>
<span :if={@events != []} class="text-sm text-base-content/70">
{length(@events)} events
</span>
</div>
<div :if={@events == []} class="text-base-content/70 py-4">
No recent activity
</div>
<div :if={@events != []} class="mt-2">
<button
class="btn btn-ghost btn-sm gap-1"
phx-click="toggle_events"
>
<.icon
name={if @events_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
class="size-4"
/>
{if @events_expanded, do: "Hide", else: "Show"} Activity
</button>
<.table
:if={@events_expanded}
id="events"
rows={@events}
row_id={&"event-#{&1.date}"}
>
<:col :let={event} label="Date">
{format_datetime(event.date)}
</:col>
<:col :let={event} label="Type">
<span class="badge badge-ghost">{event.type}</span>
</:col>
<:col :let={event} label="Description">
{event.description}
</:col>
<:col :let={event} label="Amount">
<span class={amount_color(event.amount)}>{format_currency(event.amount)}</span>
</:col>
</.table>
</div>
</div>
</div>
</div>
</div>
"""
@@ -166,7 +331,11 @@ defmodule TraderexWeb.DashboardLive do
defp format_currency(nil), do: "-"
defp format_currency(amount) when is_number(amount) do
defp format_currency(amount) when is_integer(amount) do
format_currency(amount / 1)
end
defp format_currency(amount) when is_float(amount) do
sign = if amount < 0, do: "-", else: ""
"#{sign}$#{:erlang.float_to_binary(abs(amount), decimals: 2)}"
end
@@ -189,7 +358,40 @@ defmodule TraderexWeb.DashboardLive do
Calendar.strftime(dt, "%Y-%m-%d")
end
defp format_date_only(nil), do: "-"
defp format_date_only(%Date{} = date) do
Calendar.strftime(date, "%Y-%m-%d")
end
defp format_percent(nil), do: "-"
defp format_percent(percent) when is_number(percent) do
sign = if percent >= 0, do: "+", else: ""
"#{sign}#{:erlang.float_to_binary(percent / 1, decimals: 2)}%"
end
defp format_datetime(nil), do: "-"
defp format_datetime(%DateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end
defp pl_color(nil), do: nil
defp pl_color(amount) when amount >= 0, do: "text-success"
defp pl_color(_amount), do: "text-error"
defp amount_color(nil), do: nil
defp amount_color(amount) when amount > 0, do: "text-success"
defp amount_color(amount) when amount < 0, do: "text-error"
defp amount_color(_amount), do: nil
defp format_period_label("WEEK"), do: "1W"
defp format_period_label("MONTH"), do: "1M"
defp format_period_label("YTD"), do: "YTD"
defp format_period_label("YEAR"), do: "1Y"
defp format_period_label("YEAR_3"), do: "3Y"
defp format_period_label("YEAR_5"), do: "5Y"
defp format_period_label("ALL"), do: "All"
defp format_period_label(period), do: period
end

View File

@@ -0,0 +1,38 @@
defmodule TraderexWeb.LoginLive do
use TraderexWeb, :live_view
@impl true
def mount(_params, _session, socket) do
form = to_form(%{"username" => "", "password" => ""}, as: :credentials)
{:ok, assign(socket, form: form)}
end
@impl true
def render(assigns) do
~H"""
<div class="min-h-screen flex items-center justify-center">
<div class="card bg-base-200 shadow-xl w-full max-w-md">
<div class="card-body">
<h2 class="card-title text-2xl justify-center mb-4">Traderex Login</h2>
<.flash :if={@flash[:error]} kind={:error} flash={@flash} />
<.form
for={@form}
id="login-form"
action={~p"/auth/login"}
method="post"
class="space-y-4"
>
<.input field={@form[:username]} type="text" label="Username" required />
<.input field={@form[:password]} type="password" label="Password" required />
<.button type="submit" variant="primary" class="w-full">
Sign In
</.button>
</.form>
</div>
</div>
</div>
"""
end
end

View File

@@ -0,0 +1,20 @@
defmodule TraderexWeb.Plugs.RequireAuth do
@moduledoc """
Plug that requires authentication to access protected routes.
"""
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts
def call(conn, _opts) do
if TraderexWeb.Auth.authenticated?(conn) do
conn
else
conn
|> redirect(to: "/login")
|> halt()
end
end
end

View File

@@ -14,9 +14,21 @@ defmodule TraderexWeb.Router do
plug :accepts, ["json"]
end
pipeline :require_auth do
plug TraderexWeb.Plugs.RequireAuth
end
scope "/", TraderexWeb do
pipe_through :browser
live "/login", LoginLive
post "/auth/login", AuthController, :login
delete "/auth/logout", AuthController, :logout
end
scope "/", TraderexWeb do
pipe_through [:browser, :require_auth]
live "/", DashboardLive
end

View File

@@ -3,13 +3,38 @@ defmodule TraderexWeb.DashboardLiveTest do
import Phoenix.LiveViewTest
test "renders dashboard", %{conn: conn} do
setup do
# Set test credentials
Application.put_env(:traderex, :dashboard_user, "testuser")
Application.put_env(:traderex, :dashboard_password, "testpass")
on_exit(fn ->
Application.delete_env(:traderex, :dashboard_user)
Application.delete_env(:traderex, :dashboard_password)
end)
:ok
end
defp log_in(conn) do
conn
|> Plug.Test.init_test_session(%{})
|> Plug.Conn.put_session(:authenticated, true)
end
test "redirects to login when not authenticated", %{conn: conn} do
assert {:error, {:redirect, %{to: "/login"}}} = live(conn, ~p"/")
end
test "renders dashboard when authenticated", %{conn: conn} do
conn = log_in(conn)
{:ok, view, html} = live(conn, ~p"/")
assert html =~ "Traderex Dashboard"
assert has_element?(view, "header", "Account Overview")
end
test "shows loading state initially", %{conn: conn} do
test "shows loading state initially when authenticated", %{conn: conn} do
conn = log_in(conn)
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ "loading loading-spinner"
end

View File

@@ -0,0 +1,11 @@
defmodule TraderexWeb.LoginLiveTest do
use TraderexWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "renders login form", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/login")
assert html =~ "Traderex Login"
assert has_element?(view, "#login-form")
end
end