"""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