391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""Extended tests for alerts.py - price target alerts."""
|
|
|
|
import json
|
|
import sys
|
|
from argparse import Namespace
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
from io import StringIO
|
|
|
|
import pytest
|
|
|
|
# Add scripts to path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
|
|
|
from alerts import (
|
|
load_alerts,
|
|
save_alerts,
|
|
get_alert_by_ticker,
|
|
format_price,
|
|
cmd_list,
|
|
cmd_set,
|
|
cmd_delete,
|
|
cmd_snooze,
|
|
cmd_update,
|
|
SUPPORTED_CURRENCIES,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_alerts_data():
|
|
"""Sample alerts data for testing."""
|
|
return {
|
|
"_meta": {"version": 1, "supported_currencies": SUPPORTED_CURRENCIES},
|
|
"alerts": [
|
|
{
|
|
"ticker": "AAPL",
|
|
"target_price": 150.0,
|
|
"currency": "USD",
|
|
"note": "Buy Apple",
|
|
"set_by": "art",
|
|
"set_date": "2026-01-15",
|
|
"status": "active",
|
|
"snooze_until": None,
|
|
"triggered_count": 0,
|
|
"last_triggered": None,
|
|
},
|
|
{
|
|
"ticker": "TSLA",
|
|
"target_price": 200.0,
|
|
"currency": "USD",
|
|
"note": "Buy Tesla",
|
|
"set_by": "",
|
|
"set_date": "2026-01-20",
|
|
"status": "active",
|
|
"snooze_until": None,
|
|
"triggered_count": 5,
|
|
"last_triggered": "2026-01-26T10:00:00",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def alerts_file(tmp_path, sample_alerts_data):
|
|
"""Create a temporary alerts file."""
|
|
alerts_path = tmp_path / "alerts.json"
|
|
alerts_path.write_text(json.dumps(sample_alerts_data))
|
|
return alerts_path
|
|
|
|
|
|
class TestLoadAlerts:
|
|
"""Tests for load_alerts()."""
|
|
|
|
def test_load_existing_file(self, alerts_file, monkeypatch):
|
|
"""Load alerts from existing file."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
data = load_alerts()
|
|
|
|
assert "_meta" in data
|
|
assert len(data["alerts"]) == 2
|
|
assert data["alerts"][0]["ticker"] == "AAPL"
|
|
|
|
def test_load_missing_file(self, tmp_path, monkeypatch):
|
|
"""Return default structure when file doesn't exist."""
|
|
missing_path = tmp_path / "missing.json"
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", missing_path)
|
|
|
|
data = load_alerts()
|
|
|
|
assert data["_meta"]["version"] == 1
|
|
assert data["alerts"] == []
|
|
assert "supported_currencies" in data["_meta"]
|
|
|
|
|
|
class TestSaveAlerts:
|
|
"""Tests for save_alerts()."""
|
|
|
|
def test_save_updates_timestamp(self, tmp_path, sample_alerts_data, monkeypatch):
|
|
"""Save should update the updated_at field."""
|
|
alerts_path = tmp_path / "alerts.json"
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_path)
|
|
|
|
save_alerts(sample_alerts_data)
|
|
|
|
saved = json.loads(alerts_path.read_text())
|
|
assert "updated_at" in saved["_meta"]
|
|
|
|
def test_save_preserves_data(self, tmp_path, sample_alerts_data, monkeypatch):
|
|
"""Save should preserve all alert data."""
|
|
alerts_path = tmp_path / "alerts.json"
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_path)
|
|
|
|
save_alerts(sample_alerts_data)
|
|
|
|
saved = json.loads(alerts_path.read_text())
|
|
assert len(saved["alerts"]) == 2
|
|
assert saved["alerts"][0]["ticker"] == "AAPL"
|
|
|
|
|
|
class TestGetAlertByTicker:
|
|
"""Tests for get_alert_by_ticker()."""
|
|
|
|
def test_find_existing_alert(self, sample_alerts_data):
|
|
"""Find alert by ticker."""
|
|
alerts = sample_alerts_data["alerts"]
|
|
result = get_alert_by_ticker(alerts, "AAPL")
|
|
|
|
assert result is not None
|
|
assert result["ticker"] == "AAPL"
|
|
assert result["target_price"] == 150.0
|
|
|
|
def test_find_case_insensitive(self, sample_alerts_data):
|
|
"""Find alert regardless of case."""
|
|
alerts = sample_alerts_data["alerts"]
|
|
result = get_alert_by_ticker(alerts, "aapl")
|
|
|
|
assert result is not None
|
|
assert result["ticker"] == "AAPL"
|
|
|
|
def test_not_found_returns_none(self, sample_alerts_data):
|
|
"""Return None for non-existent ticker."""
|
|
alerts = sample_alerts_data["alerts"]
|
|
result = get_alert_by_ticker(alerts, "GOOG")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestFormatPrice:
|
|
"""Tests for format_price()."""
|
|
|
|
def test_format_usd(self):
|
|
"""Format USD price."""
|
|
assert format_price(150.50, "USD") == "$150.50"
|
|
assert format_price(1234.56, "USD") == "$1,234.56"
|
|
|
|
def test_format_eur(self):
|
|
"""Format EUR price."""
|
|
assert format_price(100.00, "EUR") == "€100.00"
|
|
|
|
def test_format_jpy(self):
|
|
"""Format JPY without decimals."""
|
|
assert format_price(15000, "JPY") == "¥15,000"
|
|
|
|
def test_format_sgd(self):
|
|
"""Format SGD price."""
|
|
assert format_price(50.00, "SGD") == "S$50.00"
|
|
|
|
def test_format_mxn(self):
|
|
"""Format MXN price."""
|
|
assert format_price(100.00, "MXN") == "MX$100.00"
|
|
|
|
def test_format_unknown_currency(self):
|
|
"""Format unknown currency with code prefix."""
|
|
result = format_price(100.00, "GBP")
|
|
assert "GBP" in result
|
|
assert "100.00" in result
|
|
|
|
|
|
class TestCmdList:
|
|
"""Tests for cmd_list()."""
|
|
|
|
def test_list_empty_alerts(self, tmp_path, monkeypatch, capsys):
|
|
"""List with no alerts."""
|
|
alerts_path = tmp_path / "alerts.json"
|
|
alerts_path.write_text(json.dumps({"_meta": {}, "alerts": []}))
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_path)
|
|
|
|
cmd_list(Namespace())
|
|
|
|
captured = capsys.readouterr()
|
|
assert "No price alerts set" in captured.out
|
|
|
|
def test_list_active_alerts(self, alerts_file, monkeypatch, capsys):
|
|
"""List active alerts."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
cmd_list(Namespace())
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Price Alerts" in captured.out
|
|
assert "AAPL" in captured.out
|
|
assert "$150.00" in captured.out
|
|
|
|
def test_list_snoozed_alerts(self, tmp_path, monkeypatch, capsys):
|
|
"""List snoozed alerts separately."""
|
|
future = (datetime.now() + timedelta(days=7)).isoformat()
|
|
data = {
|
|
"_meta": {},
|
|
"alerts": [
|
|
{"ticker": "AAPL", "target_price": 150, "currency": "USD", "snooze_until": future}
|
|
],
|
|
}
|
|
alerts_path = tmp_path / "alerts.json"
|
|
alerts_path.write_text(json.dumps(data))
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_path)
|
|
|
|
cmd_list(Namespace())
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Snoozed" in captured.out
|
|
assert "AAPL" in captured.out
|
|
|
|
|
|
class TestCmdSet:
|
|
"""Tests for cmd_set()."""
|
|
|
|
def test_set_new_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Set a new alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
with patch("alerts.get_fetch_market_data") as mock_fmd:
|
|
mock_fmd.return_value = Mock(return_value={"GOOG": {"price": 175.0}})
|
|
|
|
args = Namespace(ticker="GOOG", target=150.0, currency="USD", note="Buy Google", user="art")
|
|
cmd_set(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Alert set: GOOG" in captured.out
|
|
|
|
data = json.loads(alerts_file.read_text())
|
|
goog = next((a for a in data["alerts"] if a["ticker"] == "GOOG"), None)
|
|
assert goog is not None
|
|
assert goog["target_price"] == 150.0
|
|
|
|
def test_set_duplicate_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Cannot set duplicate alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", target=140.0, currency="USD", note="", user="")
|
|
cmd_set(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "already exists" in captured.out
|
|
|
|
def test_set_invalid_target(self, alerts_file, monkeypatch, capsys):
|
|
"""Reject invalid target price."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="GOOG", target=-10.0, currency="USD", note="", user="")
|
|
cmd_set(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "must be greater than 0" in captured.out
|
|
|
|
def test_set_invalid_currency(self, alerts_file, monkeypatch, capsys):
|
|
"""Reject invalid currency."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="GOOG", target=150.0, currency="XYZ", note="", user="")
|
|
cmd_set(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "not supported" in captured.out
|
|
|
|
|
|
class TestCmdDelete:
|
|
"""Tests for cmd_delete()."""
|
|
|
|
def test_delete_existing_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Delete an existing alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL")
|
|
cmd_delete(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Alert deleted: AAPL" in captured.out
|
|
|
|
data = json.loads(alerts_file.read_text())
|
|
assert not any(a["ticker"] == "AAPL" for a in data["alerts"])
|
|
|
|
def test_delete_nonexistent_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Cannot delete non-existent alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="GOOG")
|
|
cmd_delete(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "No alert found" in captured.out
|
|
|
|
|
|
class TestCmdSnooze:
|
|
"""Tests for cmd_snooze()."""
|
|
|
|
def test_snooze_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Snooze an alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", days=7)
|
|
cmd_snooze(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Alert snoozed: AAPL" in captured.out
|
|
|
|
data = json.loads(alerts_file.read_text())
|
|
aapl = next(a for a in data["alerts"] if a["ticker"] == "AAPL")
|
|
assert aapl["snooze_until"] is not None
|
|
|
|
def test_snooze_nonexistent_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Cannot snooze non-existent alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="GOOG", days=7)
|
|
cmd_snooze(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "No alert found" in captured.out
|
|
|
|
def test_snooze_default_days(self, alerts_file, monkeypatch, capsys):
|
|
"""Default snooze is 7 days."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", days=None)
|
|
cmd_snooze(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Alert snoozed" in captured.out
|
|
|
|
|
|
class TestCmdUpdate:
|
|
"""Tests for cmd_update()."""
|
|
|
|
def test_update_target_price(self, alerts_file, monkeypatch, capsys):
|
|
"""Update alert target price."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", target=140.0, note=None)
|
|
cmd_update(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Alert updated: AAPL" in captured.out
|
|
assert "$150.00" in captured.out # Old price
|
|
assert "$140.00" in captured.out # New price
|
|
|
|
data = json.loads(alerts_file.read_text())
|
|
aapl = next(a for a in data["alerts"] if a["ticker"] == "AAPL")
|
|
assert aapl["target_price"] == 140.0
|
|
|
|
def test_update_with_note(self, alerts_file, monkeypatch, capsys):
|
|
"""Update alert with new note."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", target=145.0, note="New buy zone")
|
|
cmd_update(args)
|
|
|
|
data = json.loads(alerts_file.read_text())
|
|
aapl = next(a for a in data["alerts"] if a["ticker"] == "AAPL")
|
|
assert aapl["note"] == "New buy zone"
|
|
|
|
def test_update_nonexistent_alert(self, alerts_file, monkeypatch, capsys):
|
|
"""Cannot update non-existent alert."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="GOOG", target=150.0, note=None)
|
|
cmd_update(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "No alert found" in captured.out
|
|
|
|
def test_update_invalid_target(self, alerts_file, monkeypatch, capsys):
|
|
"""Reject invalid target price on update."""
|
|
monkeypatch.setattr("alerts.ALERTS_FILE", alerts_file)
|
|
|
|
args = Namespace(ticker="AAPL", target=-10.0, note=None)
|
|
cmd_update(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "must be greater than 0" in captured.out
|