scaffolded PySwiss microservice: Django 6.0 project at pyswiss/ (core/ settings, apps/charts/), GET /api/chart/ + GET /api/charts/ views, EphemerisSnapshot model + migration, populate_ephemeris management command, 41 ITs (integrated + unit, all green); separate .venv with pyswisseph 2.10.3.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
|
||||
|
||||
These tests drive the EphemerisSnapshot model and list view.
|
||||
Snapshots are created directly in setUp — no live ephemeris calc needed.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHART_DATA_STUB = {
|
||||
'planets': {
|
||||
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
|
||||
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
|
||||
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
|
||||
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
|
||||
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
|
||||
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
|
||||
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
|
||||
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
|
||||
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
|
||||
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
|
||||
},
|
||||
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
|
||||
}
|
||||
|
||||
|
||||
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
|
||||
chart_data=None):
|
||||
return EphemerisSnapshot.objects.create(
|
||||
dt=dt_str,
|
||||
fire=fire, water=water, earth=earth, air=air,
|
||||
time_el=time_el, space_el=space_el,
|
||||
chart_data=chart_data or CHART_DATA_STUB,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChartsListApiTest(TestCase):
|
||||
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
|
||||
|
||||
def setUp(self):
|
||||
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
|
||||
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
|
||||
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
|
||||
# Outside the usual date range — should not appear in filtered results
|
||||
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
|
||||
|
||||
def _get(self, params=None):
|
||||
return self.client.get('/api/charts/', params or {})
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_400_if_date_from_missing(self):
|
||||
response = self._get({'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_missing(self):
|
||||
response = self._get({'date_from': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_for_invalid_date_from(self):
|
||||
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_before_date_from(self):
|
||||
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_200_for_valid_params(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_charts_response_is_json(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_charts_response_has_results_and_count(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertIn('results', data)
|
||||
self.assertIn('count', data)
|
||||
|
||||
def test_each_result_has_dt_and_elements(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('dt', result)
|
||||
self.assertIn('elements', result)
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
self.assertIn(key, result['elements'])
|
||||
|
||||
def test_each_result_has_planets(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('planets', result)
|
||||
|
||||
# ── date range filtering ──────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_only_snapshots_in_date_range(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertEqual(data['count'], 3)
|
||||
|
||||
def test_charts_count_matches_results_length(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
|
||||
self.assertEqual(data['count'], len(data['results']))
|
||||
|
||||
def test_charts_date_range_is_inclusive(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_results_ordered_by_dt(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
dts = [r['dt'] for r in data['results']]
|
||||
self.assertEqual(dts, sorted(dts))
|
||||
|
||||
# ── element range filtering ───────────────────────────────────────────
|
||||
|
||||
def test_charts_filters_by_fire_min(self):
|
||||
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_water_min(self):
|
||||
# Only the Jan 2 snapshot has water=4
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_earth_min(self):
|
||||
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_multiple_element_filters_are_conjunctive(self):
|
||||
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31',
|
||||
'fire_min': 2, 'water_min': 2,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 2)
|
||||
126
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
126
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Integration tests for the PySwiss chart calculation API.
|
||||
|
||||
These tests drive the TDD implementation of GET /api/chart/.
|
||||
They verify the HTTP contract using Django's test client.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
|
||||
J2000 = '2000-01-01T12:00:00Z'
|
||||
LONDON = {'lat': 51.5074, 'lon': -0.1278}
|
||||
|
||||
|
||||
class ChartApiTest(TestCase):
|
||||
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
|
||||
|
||||
def _get(self, params):
|
||||
return self.client.get('/api/chart/', params)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_400_if_dt_missing(self):
|
||||
response = self._get({'lat': 51.5074, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lat_missing(self):
|
||||
response = self._get({'dt': J2000, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lon_missing(self):
|
||||
response = self._get({'dt': J2000, 'lat': 51.5074})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_invalid_dt_format(self):
|
||||
response = self._get({'dt': 'not-a-date', **LONDON})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_out_of_range_lat(self):
|
||||
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_200_for_valid_params(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_chart_response_is_json(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_chart_returns_all_ten_planets(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
expected = {
|
||||
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
|
||||
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
|
||||
}
|
||||
self.assertEqual(set(data['planets'].keys()), expected)
|
||||
|
||||
def test_each_planet_has_sign_degree_and_retrograde(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for name, planet in data['planets'].items():
|
||||
with self.subTest(planet=name):
|
||||
self.assertIn('sign', planet)
|
||||
self.assertIn('degree', planet)
|
||||
self.assertIn('retrograde', planet)
|
||||
|
||||
def test_chart_returns_houses(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
houses = data['houses']
|
||||
self.assertEqual(len(houses['cusps']), 12)
|
||||
self.assertIn('asc', houses)
|
||||
self.assertIn('mc', houses)
|
||||
|
||||
def test_chart_returns_six_element_counts(self):
|
||||
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
with self.subTest(element=key):
|
||||
self.assertIn(key, data['elements'])
|
||||
|
||||
def test_chart_reports_active_house_system(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('house_system', data)
|
||||
|
||||
# ── calculation correctness ───────────────────────────────────────────
|
||||
|
||||
def test_sun_is_in_capricorn_at_j2000(self):
|
||||
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
sun = data['planets']['Sun']
|
||||
self.assertEqual(sun['sign'], 'Capricorn')
|
||||
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
|
||||
|
||||
def test_sun_is_not_retrograde(self):
|
||||
"""The Sun never goes retrograde."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertFalse(data['planets']['Sun']['retrograde'])
|
||||
|
||||
def test_element_counts_sum_to_ten(self):
|
||||
"""All 10 planets are assigned to exactly one classical element."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
classical = sum(
|
||||
data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||
)
|
||||
self.assertEqual(classical, 10)
|
||||
|
||||
# ── house system ──────────────────────────────────────────────────────
|
||||
|
||||
def test_default_house_system_is_porphyry(self):
|
||||
"""Porphyry ('O') is the project default — no param needed."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertEqual(data['house_system'], 'O')
|
||||
|
||||
def test_non_superuser_cannot_override_house_system(self):
|
||||
"""House system override is superuser-only; plain requests get 403."""
|
||||
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
Reference in New Issue
Block a user