"""Integration tests for the My Sky dashboard views. sky_view — GET /dashboard/sky/ → renders sky template sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write) sky_save — POST /dashboard/sky/save → saves natal data to User model; grants Stargazer Note on first save with real chart_data """ import json from unittest.mock import patch, MagicMock from django.test import TestCase from django.urls import reverse from apps.drama.models import Note from apps.lyric.models import User class SkyViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="star@test.io") self.client.force_login(self.user) def test_sky_view_renders_template(self): response = self.client.get(reverse("sky")) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/dashboard/sky.html") def test_sky_view_requires_login(self): self.client.logout() response = self.client.get(reverse("sky")) self.assertEqual(response.status_code, 302) self.assertIn("/?next=", response["Location"]) def test_sky_view_passes_preview_and_save_urls(self): response = self.client.get(reverse("sky")) self.assertContains(response, reverse("sky_preview")) self.assertContains(response, reverse("sky_save")) def test_saved_birth_date_renders_in_user_tz_when_set(self): """A user w. saved sky_birth_dt + sky_birth_tz hits the astimezone branch (views.py L300-306) — saved_birth_date / saved_birth_time render in the user's local tz, not UTC.""" from datetime import datetime import zoneinfo # 1990-06-15 16:00 UTC = 12:00 PM in America/New_York (EDT, UTC-4) self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC")) self.user.sky_birth_tz = "America/New_York" self.user.save() response = self.client.get(reverse("sky")) self.assertEqual(response.context["saved_birth_date"], "1990-06-15") self.assertEqual(response.context["saved_birth_time"], "12:00") def test_saved_birth_falls_back_to_utc_when_tz_invalid(self): """A garbage sky_birth_tz triggers ZoneInfoNotFoundError — the view swallows it (pass) and renders the UTC representation.""" from datetime import datetime import zoneinfo self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC")) self.user.sky_birth_tz = "Not/A/Real_Zone" self.user.save() response = self.client.get(reverse("sky")) # UTC fallback — 16:00 stays 16:00 self.assertEqual(response.context["saved_birth_time"], "16:00") def test_tz_input_is_readonly_and_carries_auto_detect_placeholder(self): """Manual TZ edits throw the schedulePreview / PySwiss fetch off (the backend gets a stale TZ for the new lat/lon), so the field is render- readonly like lat/lon — auto-fills from preview, never from a typed override. The old is gone; its copy lives in the placeholder so an empty field is self-explanatory.""" response = self.client.get(reverse("sky")) self.assertContains(response, 'id="id_nf_tz"') # readonly + tabindex:-1 mirrors the lat/lon pattern. self.assertRegex( response.content.decode(), r'id="id_nf_tz"[^>]*\breadonly\b', ) self.assertContains(response, 'placeholder="auto-detected from coordinates"') self.assertNotContains(response, 'id="id_nf_tz_hint"') class SkyPreviewTest(TestCase): def setUp(self): self.user = User.objects.create(email="star@test.io") self.client.force_login(self.user) self.url = reverse("sky_preview") def test_requires_login(self): self.client.logout() response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) self.assertEqual(response.status_code, 302) self.assertIn("/?next=", response["Location"]) def test_missing_params_returns_400(self): response = self.client.get(self.url, {"date": "1990-06-15"}) self.assertEqual(response.status_code, 400) def test_invalid_lat_returns_400(self): response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"}) self.assertEqual(response.status_code, 400) @patch("apps.dashboard.views.http_requests") def test_proxies_to_pyswiss_and_returns_chart(self, mock_requests): chart_payload = { "planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}}, "houses": {"cusps": [0]*12}, "elements": {"Fire": 1, "Earth": 0, "Air": 0, "Water": 0}, "house_system": "O", } tz_response = MagicMock() tz_response.json.return_value = {"timezone": "Europe/London"} tz_response.raise_for_status = MagicMock() chart_response = MagicMock() chart_response.json.return_value = chart_payload chart_response.raise_for_status = MagicMock() mock_requests.get.side_effect = [tz_response, chart_response] response = self.client.get(self.url, { "date": "1990-06-15", "time": "09:30", "lat": "51.5074", "lon": "-0.1278", }) self.assertEqual(response.status_code, 200) data = response.json() self.assertIn("planets", data) # Earth→Stone rename applied self.assertIn("Stone", data["elements"]) self.assertNotIn("Earth", data["elements"]) self.assertIn("timezone", data) self.assertIn("distinctions", data) class SkySaveTest(TestCase): def setUp(self): self.user = User.objects.create(email="star@test.io") self.client.force_login(self.user) self.url = reverse("sky_save") def _post(self, payload): return self.client.post( self.url, data=json.dumps(payload), content_type="application/json", ) def test_requires_login(self): self.client.logout() response = self._post({}) self.assertEqual(response.status_code, 302) self.assertIn("/?next=", response["Location"]) def test_get_not_allowed(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 405) def test_saves_sky_fields_to_user(self): payload = { "birth_dt": "1990-06-15T08:30:00", "birth_lat": 51.5074, "birth_lon": -0.1278, "birth_place": "London, UK", "house_system": "O", "chart_data": {}, } response = self._post(payload) self.assertEqual(response.status_code, 200) self.user.refresh_from_db() self.assertEqual(str(self.user.sky_birth_dt), "1990-06-15 08:30:00+00:00") self.assertAlmostEqual(float(self.user.sky_birth_lat), 51.5074, places=3) self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3) self.assertEqual(self.user.sky_birth_place, "London, UK") self.assertEqual(self.user.sky_house_system, "O") def test_invalid_json_returns_400(self): response = self.client.post( self.url, data="not json", content_type="application/json" ) self.assertEqual(response.status_code, 400) def test_response_contains_saved_flag(self): payload = { "birth_dt": "1990-06-15T08:30:00", "birth_lat": 51.5, "birth_lon": -0.1, "birth_place": "", "house_system": "O", "chart_data": {}, } data = self._post(payload).json() self.assertTrue(data["saved"]) def test_invalid_birth_dt_string_sets_sky_birth_dt_to_none(self): payload = { "birth_dt": "not-a-date", "birth_lat": 51.5, "birth_lon": -0.1, "birth_place": "", "house_system": "O", "chart_data": {}, } response = self._post(payload) self.assertEqual(response.status_code, 200) self.user.refresh_from_db() self.assertIsNone(self.user.sky_birth_dt) class SkyPreviewErrorPathTest(TestCase): def setUp(self): self.user = User.objects.create(email="star2@test.io") self.client.force_login(self.user) self.url = reverse("sky_preview") def test_non_numeric_lat_returns_400(self): response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"}) self.assertEqual(response.status_code, 400) def test_invalid_tz_string_returns_400(self): response = self.client.get( self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/ATimezone"} ) self.assertEqual(response.status_code, 400) def test_bad_date_format_returns_400(self): response = self.client.get( self.url, {"date": "not-a-date", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"}, ) self.assertEqual(response.status_code, 400) @patch("apps.dashboard.views.http_requests") def test_pyswiss_tz_failure_falls_back_to_utc_and_continues(self, mock_requests): chart_payload = { "planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}}, "houses": {"cusps": [0] * 12}, "elements": {}, "house_system": "O", } tz_response = MagicMock() tz_response.raise_for_status.side_effect = Exception("tz timeout") chart_response = MagicMock() chart_response.json.return_value = chart_payload chart_response.raise_for_status = MagicMock() mock_requests.get.side_effect = [tz_response, chart_response] response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["timezone"], "UTC") @patch("apps.dashboard.views.http_requests") def test_pyswiss_chart_failure_returns_502(self, mock_requests): tz_response = MagicMock() tz_response.json.return_value = {"timezone": "UTC"} tz_response.raise_for_status = MagicMock() chart_response = MagicMock() chart_response.raise_for_status.side_effect = Exception("chart timeout") mock_requests.get.side_effect = [tz_response, chart_response] response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"}) self.assertEqual(response.status_code, 502) _REAL_CHART = { "planets": {"Sun": {"degree": 66.7, "sign": "Gemini", "retrograde": False}}, "houses": {"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150]}, "elements": {"Fire": 1, "Stone": 2, "Air": 4, "Water": 0, "Time": 1, "Space": 1}, "aspects": [], } class SkySaveNoteTest(TestCase): """sky_save grants the Stargazer Note on the first save with real chart_data.""" def setUp(self): self.user = User.objects.create(email="star@test.io") self.client.force_login(self.user) self.url = reverse("sky_save") def _post(self, chart_data=_REAL_CHART): return self.client.post( self.url, data=json.dumps({ "birth_dt": "1990-06-15T08:30:00", "birth_lat": 51.5074, "birth_lon": -0.1278, "birth_place": "London, UK", "birth_tz": "Europe/London", "house_system": "O", "chart_data": chart_data, }), content_type="application/json", ) def test_first_save_with_chart_data_returns_stargazer_brief(self): data = self._post().json() self.assertIn("brief", data) brief = data["brief"] self.assertEqual(brief["kind"], "note_unlock") self.assertEqual(brief["title"], "Stargazer") self.assertIn("Stargazer", brief["line_text"]) self.assertIn("/billboard/post/", brief["post_url"]) self.assertEqual(brief["square_url"], "/billboard/my-notes/") self.assertIn("created_at", brief) def test_first_save_creates_note_in_db(self): self._post() self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1) def test_second_save_returns_null_brief(self): self._post() data = self._post().json() self.assertIsNone(data["brief"]) def test_second_save_does_not_create_duplicate_note(self): self._post() self._post() self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1) def test_save_with_empty_chart_data_does_not_grant_note(self): data = self._post(chart_data={}).json() self.assertIsNone(data["brief"]) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0) def test_save_with_null_chart_data_does_not_grant_note(self): data = self._post(chart_data=None).json() self.assertIsNone(data["brief"]) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0) class SkyDeleteTest(TestCase): """POST /dashboard/sky/delete clears all sky fields on the User model and redirects back to /dashboard/sky/. The Stargazer Note is preserved (it's earned, not stateful).""" def setUp(self): from datetime import datetime import zoneinfo self.user = User.objects.create(email="star@test.io") self.user.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}} self.user.sky_birth_place = "Baltimore, MD, US" self.user.sky_birth_tz = "America/New_York" self.user.sky_birth_lat = 39.2904 self.user.sky_birth_lon = -76.6122 self.user.sky_birth_dt = datetime( 1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC") ) self.user.sky_house_system = "O" self.user.save() self.client.force_login(self.user) self.url = reverse("sky_delete") def test_post_clears_all_sky_fields(self): self.client.post(self.url) self.user.refresh_from_db() self.assertIsNone(self.user.sky_chart_data) self.assertIsNone(self.user.sky_birth_dt) self.assertIsNone(self.user.sky_birth_lat) self.assertIsNone(self.user.sky_birth_lon) self.assertEqual(self.user.sky_birth_place, "") self.assertEqual(self.user.sky_birth_tz, "") def test_post_redirects_to_sky_view(self): response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], reverse("sky")) def test_get_returns_405(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 405) def test_requires_login(self): self.client.logout() response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertIn("/?next=", response["Location"]) def test_post_preserves_unrelated_user_fields(self): self.user.username = "stargazer-keepme" self.user.save() self.client.post(self.url) self.user.refresh_from_db() self.assertEqual(self.user.username, "stargazer-keepme")