diff --git a/edupage_api/testing.py b/edupage_api/testing.py new file mode 100644 index 0000000..93b6c3f --- /dev/null +++ b/edupage_api/testing.py @@ -0,0 +1,290 @@ +"""Testing utilities for edupage-api. + +This module provides three components that together allow writing offline, +reproducible tests against the edupage-api library without a real Edupage +account or live network access: + +* :class:`EduRecorder` — context manager that intercepts a live session and + writes every HTTP exchange (plus the parsed ``edupage.data`` blob) to a JSON + fixture file. + +* :class:`MockSession` — a :class:`requests.Session` replacement that replays + responses from a previously recorded fixture file instead of making real + network requests. + +* :class:`EdupageTestCase` — a :class:`unittest.TestCase` base class that + exposes :meth:`~EdupageTestCase.get_edupage`, a factory that loads a fixture, + wires up a :class:`MockSession`, and returns a fully-initialised + :class:`~edupage_api.Edupage` instance ready for testing — without any login + flow. + +Typical workflow +---------------- + +1. **Record** a real session once (requires a real account):: + + from edupage_api import Edupage + from edupage_api.testing import EduRecorder + + edupage = Edupage() + with EduRecorder(edupage, "tests/fixtures/grades.json"): + edupage.login("user", "pass", "myschool") + edupage.get_grades() + # fixture file written on context-manager exit + +2. *Optionally* anonymise the fixture by hand or via the ``anonymiser`` + callback. + +3. **Replay** in tests (no credentials, no network):: + + from edupage_api.testing import EdupageTestCase + + class GradesTest(EdupageTestCase): + def test_grade_count(self): + edupage = self.get_edupage("tests/fixtures/grades.json") + grades = edupage.get_grades() + self.assertEqual(len(grades), 5) + +Fixture format +-------------- + +:: + + { + "meta": { + "subdomain": "myschool", + "username": "student123", + "gsec_hash": "abc123" + }, + "data": { /* full edupage.data dict (userhome payload) */ }, + "requests": [ + { + "method": "GET", + "url": "https://myschool.edupage.org/znamky/", + "response": { + "status_code": 200, + "text": "... page body ..." + } + } + ] + } + +Multiple fixtures can be kept for the same feature to cover different server +response variants (e.g. ``grades_school_a.json`` vs ``grades_school_b.json``). +""" + +import json +import unittest +from typing import Callable, Optional + +from requests import Response, Session +from requests.structures import CaseInsensitiveDict + + +class UnexpectedRequestError(Exception): + """Raised by :class:`MockSession` when a request has no matching fixture entry. + + This keeps tests deterministic: a missing fixture entry is a hard error + rather than a silent network call. + """ + + +def _make_response(status_code: int, text: str, url: str) -> Response: + """Build a minimal :class:`requests.Response` from raw fixture data.""" + response = Response() + response.status_code = status_code + response._content = text.encode("utf-8") + response.encoding = "utf-8" + response.url = url + response.headers = CaseInsensitiveDict( + {"Content-Type": "text/html; charset=utf-8"} + ) + return response + + +class MockSession(Session): + """A :class:`requests.Session` that serves pre-recorded responses. + + Responses are looked up by ``(HTTP method, URL)`` key. If the same + ``(method, URL)`` pair appears more than once in the fixture, responses are + served in the order they were recorded; the last recorded response is + repeated once exhausted. + + Args: + requests_data: The ``"requests"`` list from a loaded fixture dict. + + Raises: + UnexpectedRequestError: When a ``(method, URL)`` pair is received that + has no entry in the fixture. + """ + + def __init__(self, requests_data: list): + super().__init__() + # (method, url) -> ordered list of response dicts + self._mock_responses: dict[tuple[str, str], list[dict]] = {} + for entry in requests_data: + key = (entry["method"].upper(), entry["url"]) + self._mock_responses.setdefault(key, []).append(entry["response"]) + + def send(self, request, **kwargs): + key = (request.method.upper(), request.url) + responses = self._mock_responses.get(key) + if not responses: + raise UnexpectedRequestError( + f"No mock response for {request.method} {request.url}. " + "Add this request/response pair to your fixture file." + ) + # Consume the first entry; keep the last one so it can be reused. + response_data = responses.pop(0) if len(responses) > 1 else responses[0] + return _make_response( + response_data["status_code"], response_data["text"], request.url + ) + + +class EduRecorder: + """Context manager that records all HTTP exchanges to a JSON fixture file. + + Wrap any block of :class:`~edupage_api.Edupage` calls with this context + manager. On exit the recorded request/response pairs *and* the current + ``edupage.data`` blob are written to *fixture_path* as a single JSON file. + + Args: + edupage: The :class:`~edupage_api.Edupage` instance whose session will + be instrumented. + fixture_path: Path where the fixture JSON file will be written. + anonymiser: Optional callable ``(fixture: dict) -> dict`` applied to + the fixture before writing. Use it to scrub personal data (real + names, grades, …) before committing the file to version control. + + Example:: + + from edupage_api import Edupage + from edupage_api.testing import EduRecorder + + def scrub(fixture): + fixture["meta"]["username"] = "anonymised" + return fixture + + edupage = Edupage() + with EduRecorder(edupage, "tests/fixtures/grades.json", anonymiser=scrub): + edupage.login("real_user", "real_pass", "myschool") + edupage.get_grades() + """ + + def __init__( + self, + edupage, + fixture_path: str, + anonymiser: Optional[Callable[[dict], dict]] = None, + ): + self.edupage = edupage + self.fixture_path = fixture_path + self.anonymiser = anonymiser + self._captured: list = [] + self._original_send = None + + def __enter__(self): + self._original_send = self.edupage.session.send + + def _recording_send(request, **kwargs): + response = self._original_send(request, **kwargs) + self._captured.append( + { + "method": request.method, + "url": request.url, + "response": { + "status_code": response.status_code, + "text": response.text, + }, + } + ) + return response + + self.edupage.session.send = _recording_send + return self + + def __exit__(self, *args): + self.edupage.session.send = self._original_send + + fixture = { + "meta": { + "subdomain": self.edupage.subdomain, + "username": self.edupage.username, + "gsec_hash": self.edupage.gsec_hash, + }, + "data": self.edupage.data, + "requests": self._captured, + } + + if self.anonymiser is not None: + fixture = self.anonymiser(fixture) + + with open(self.fixture_path, "w", encoding="utf-8") as f: + json.dump(fixture, f, indent=2, ensure_ascii=False) + + +class EdupageTestCase(unittest.TestCase): + """Base :class:`unittest.TestCase` with helpers for fixture-based testing. + + Subclass this instead of :class:`unittest.TestCase` to get access to + :meth:`get_edupage`. + + Example:: + + import os + from edupage_api.testing import EdupageTestCase + + FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") + + class MyTest(EdupageTestCase): + def test_something(self): + edupage = self.get_edupage(os.path.join(FIXTURES, "my_feature.json")) + result = edupage.some_method() + self.assertEqual(result, expected) + """ + + def get_edupage(self, fixture_path: str): + """Load a fixture and return a ready-to-use :class:`~edupage_api.Edupage` instance. + + The returned instance has: + + * ``data`` populated from the fixture's ``"data"`` field. + * ``subdomain``, ``username``, ``gsec_hash`` set from ``"meta"``. + * ``is_logged_in`` set to ``True`` (bypasses the ``@logged_in`` guard). + * ``session`` replaced with a :class:`MockSession` backed by the + fixture's ``"requests"`` list — no real network access occurs. + + Args: + fixture_path: Path to a JSON fixture file produced by + :class:`EduRecorder` (or hand-crafted). + + Returns: + A configured :class:`~edupage_api.Edupage` instance. + + Note: + Any ``"meta"`` fields or the ``"data"`` key that are absent from the + fixture will result in the corresponding ``Edupage`` attribute being + ``None``. Hand-crafted fixtures must include at minimum a + ``"data"`` key; missing ``"meta"`` values will cause + ``AttributeError`` or ``MissingDataException`` in methods that rely + on ``subdomain``, ``username``, or ``gsec_hash``. + """ + # Import here to avoid a circular import at module level + # (testing.py lives inside the edupage_api package). + from edupage_api import Edupage + + with open(fixture_path, encoding="utf-8") as f: + fixture = json.load(f) + + edupage = Edupage() + + meta = fixture.get("meta", {}) + edupage.subdomain = meta.get("subdomain") + edupage.username = meta.get("username") + edupage.gsec_hash = meta.get("gsec_hash") + edupage.data = fixture.get("data") + edupage.is_logged_in = True + + edupage.session = MockSession(fixture.get("requests", [])) + + return edupage diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/notifications.json b/tests/fixtures/notifications.json new file mode 100644 index 0000000..9f03e08 --- /dev/null +++ b/tests/fixtures/notifications.json @@ -0,0 +1,50 @@ +{ + "meta": { + "subdomain": "testschool", + "username": "test.student", + "gsec_hash": "testhash123" + }, + "data": { + "dbi": { + "teachers": {}, + "students": {}, + "parents": {} + }, + "userProps": { + "1": { + "starred": "0" + }, + "2": { + "starred": "1", + "doneMaxCas": "2024-02-20 09:15:00" + } + }, + "items": [ + { + "timelineid": "1", + "typ": "sprava", + "timestamp": "2024-01-15 10:30:00", + "text": "Hello from school", + "data": "{\"messageContent\": \"Hello from school\"}", + "user_meno": "Cel\u00e1 \u0161kola", + "vlastnik_meno": "*", + "pocet_reakcii": "3", + "cas_pridania": "2024-01-15 10:28:00", + "removed": "0" + }, + { + "timelineid": "2", + "typ": "homework", + "timestamp": "2024-02-19 08:00:00", + "text": "Math homework", + "data": "{\"nazov\": \"Math homework\"}", + "user_meno": "Cel\u00e1 \u0161kola", + "vlastnik_meno": "*", + "pocet_reakcii": "0", + "cas_pridania": "2024-02-19 07:55:00", + "removed": "0" + } + ] + }, + "requests": [] +} diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..493c846 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,130 @@ +import os +import unittest +from datetime import datetime + +from edupage_api.testing import EdupageTestCase, MockSession, UnexpectedRequestError +from edupage_api.timeline import EventType + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") +NOTIFICATIONS_FIXTURE = os.path.join(FIXTURES_DIR, "notifications.json") + + +class NotificationsTest(EdupageTestCase): + """Tests for get_notifications() using a pre-recorded fixture. + + These tests cover notification parsing without any live network access. + The fixture contains two synthetic timeline items: one school-wide message + and one homework notification. + """ + + def _get_notifications(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + return edupage.get_notifications() + + # ------------------------------------------------------------------ + # Basic count / structure + # ------------------------------------------------------------------ + + def test_returns_two_notifications(self): + notifications = self._get_notifications() + self.assertEqual(len(notifications), 2) + + # ------------------------------------------------------------------ + # First notification — school-wide message + # ------------------------------------------------------------------ + + def test_first_notification_event_type(self): + notifications = self._get_notifications() + self.assertEqual(notifications[0].event_type, EventType.MESSAGE) + + def test_first_notification_text(self): + notifications = self._get_notifications() + self.assertEqual(notifications[0].text, "Hello from school") + + def test_first_notification_timestamp(self): + notifications = self._get_notifications() + self.assertEqual( + notifications[0].timestamp, + datetime(2024, 1, 15, 10, 30, 0), + ) + + def test_first_notification_reaction_count(self): + notifications = self._get_notifications() + self.assertEqual(notifications[0].reaction_count, 3) + + def test_first_notification_not_starred(self): + notifications = self._get_notifications() + self.assertFalse(notifications[0].is_starred) + + def test_first_notification_not_done(self): + notifications = self._get_notifications() + self.assertFalse(notifications[0].is_done) + + def test_first_notification_not_removed(self): + notifications = self._get_notifications() + self.assertFalse(notifications[0].is_removed) + + def test_first_notification_created_at(self): + notifications = self._get_notifications() + self.assertEqual( + notifications[0].created_at, + datetime(2024, 1, 15, 10, 28, 0), + ) + + # ------------------------------------------------------------------ + # Second notification — homework (done and starred) + # ------------------------------------------------------------------ + + def test_second_notification_event_type(self): + notifications = self._get_notifications() + self.assertEqual(notifications[1].event_type, EventType.HOMEWORK) + + def test_second_notification_is_starred(self): + notifications = self._get_notifications() + self.assertTrue(notifications[1].is_starred) + + def test_second_notification_is_done(self): + notifications = self._get_notifications() + self.assertTrue(notifications[1].is_done) + + def test_second_notification_done_at(self): + notifications = self._get_notifications() + self.assertEqual( + notifications[1].done_at, + datetime(2024, 2, 20, 9, 15, 0), + ) + + # ------------------------------------------------------------------ + # MockSession behaviour + # ------------------------------------------------------------------ + + def test_get_edupage_sets_is_logged_in(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + self.assertTrue(edupage.is_logged_in) + + def test_get_edupage_sets_subdomain(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + self.assertEqual(edupage.subdomain, "testschool") + + def test_get_edupage_sets_username(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + self.assertEqual(edupage.username, "test.student") + + def test_get_edupage_sets_gsec_hash(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + self.assertEqual(edupage.gsec_hash, "testhash123") + + def test_get_edupage_installs_mock_session(self): + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + self.assertIsInstance(edupage.session, MockSession) + + def test_unexpected_request_raises_error(self): + """MockSession must raise UnexpectedRequestError for unmapped URLs.""" + edupage = self.get_edupage(NOTIFICATIONS_FIXTURE) + # The fixture has an empty requests list, so any HTTP call should fail. + with self.assertRaises(UnexpectedRequestError): + edupage.session.get("https://testschool.edupage.org/unexpected/") + + +if __name__ == "__main__": + unittest.main()