File size: 17,745 Bytes
8a85a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
"""
Tests for Admin Curriculum Reingest & Upload Endpoints.

Validates:
1. POST /api/admin/reingest-pdf non-blocking execution with BackgroundTasks
   (returns 200 OK, success=True, execution_mode="background_tasks", schedules run_cloud_ingestion_and_upload).
2. POST /api/admin/reingest-pdf with GitHub workflow dispatch
   (mocking urllib.request.urlopen returning HTTP 204 when GITHUB_PAT or GITHUB_TOKEN is set;
   returns execution_mode="github_actions").
3. POST /api/admin/upload-pdf non-blocking behavior
   (re-ingestion scheduled via BackgroundTasks rather than running synchronously).
4. GET /api/admin/reingest-status returns current REINGESTION_STATUS.
5. Role-based access control (RBAC) enforcement on all endpoints.
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import BackgroundTasks
from fastapi.testclient import TestClient

import main as main_module
from main import app
from routes.admin_routes import PDF_METADATA, REINGESTION_STATUS


@pytest.fixture
def admin_auth():
    orig_ready = getattr(main_module, "_firebase_ready", False)
    orig_init = getattr(main_module, "_init_firebase_admin", None)
    orig_auth = main_module.firebase_auth
    orig_verify = getattr(main_module.firebase_auth, "verify_id_token", None) if main_module.firebase_auth else None

    main_module._firebase_ready = True
    main_module._init_firebase_admin = lambda: None
    if not main_module.firebase_auth:
        main_module.firebase_auth = MagicMock()
    main_module.firebase_auth.verify_id_token = MagicMock(return_value={
        "uid": "admin-test-uid",
        "email": "admin@test.mathpulse.ai",
        "name": "Admin Tester",
        "role": "admin",
    })

    yield

    main_module._firebase_ready = orig_ready
    main_module._init_firebase_admin = orig_init
    main_module.firebase_auth = orig_auth
    if orig_verify and main_module.firebase_auth:
        main_module.firebase_auth.verify_id_token = orig_verify


@pytest.fixture
def client(admin_auth):
    return TestClient(app, headers={"Authorization": "Bearer admin-token"})


@pytest.fixture(autouse=True)
def reset_reingestion_state():
    REINGESTION_STATUS.clear()
    REINGESTION_STATUS.update({
        "status": "idle",
        "last_run": None,
        "message": None,
        "mode": None,
    })
    yield
    REINGESTION_STATUS.clear()
    REINGESTION_STATUS.update({
        "status": "idle",
        "last_run": None,
        "message": None,
        "mode": None,
    })


@pytest.fixture(autouse=True)
def mock_audit_logger():
    mock_logger = AsyncMock()
    with patch("routes.admin_routes._get_audit_logger", return_value=mock_logger):
        yield mock_logger


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 1. POST /api/admin/reingest-pdf tests
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestReingestPdf:
    """Tests for POST /api/admin/reingest-pdf endpoint."""

    def test_reingest_pdf_background_tasks_execution(self, client, monkeypatch):
        """Verify non-blocking execution with BackgroundTasks when no GitHub token is present."""
        monkeypatch.delenv("GITHUB_PAT", raising=False)
        monkeypatch.delenv("GITHUB_TOKEN", raising=False)

        with patch.object(BackgroundTasks, "add_task") as mock_add_task, \
             patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            response = client.post("/api/admin/reingest-pdf", json={})

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["execution_mode"] == "background_tasks"
            assert "Remote re-ingestion started in the cloud." in data["message"]

            mock_add_task.assert_called_once()
            assert mock_add_task.call_args[0][0] == mock_ingest

            assert REINGESTION_STATUS["status"] == "running"
            assert REINGESTION_STATUS["mode"] == "background_tasks"
            assert REINGESTION_STATUS["last_run"] is not None

    def test_reingest_pdf_background_tasks_invokes_task(self, client, monkeypatch):
        """Verify that run_cloud_ingestion_and_upload is scheduled and invoked by BackgroundTasks."""
        monkeypatch.delenv("GITHUB_PAT", raising=False)
        monkeypatch.delenv("GITHUB_TOKEN", raising=False)

        with patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            response = client.post("/api/admin/reingest-pdf", json={"subjectId": "general_mathematics"})

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["execution_mode"] == "background_tasks"
            mock_ingest.assert_called_once()

    def test_reingest_pdf_github_actions_dispatch_with_pat(self, client, monkeypatch):
        """Verify GitHub Actions workflow dispatch when GITHUB_PAT is set and HTTP 204 returned."""
        monkeypatch.setenv("GITHUB_PAT", "ghp_mock_pat_token_test_12345")
        monkeypatch.delenv("GITHUB_TOKEN", raising=False)

        mock_response = MagicMock()
        mock_response.status = 204
        mock_cm = MagicMock()
        mock_cm.__enter__.return_value = mock_response
        mock_cm.__exit__.return_value = None

        with patch("urllib.request.urlopen", return_value=mock_cm) as mock_urlopen, \
             patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            response = client.post("/api/admin/reingest-pdf", json={})

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["execution_mode"] == "github_actions"
            assert "dispatched to GitHub Actions runner" in data["message"]

            mock_ingest.assert_not_called()
            mock_urlopen.assert_called_once()
            req = mock_urlopen.call_args[0][0]
            assert "workflows/ingest-curriculum.yml/dispatches" in req.full_url
            assert req.headers.get("Authorization") == "Bearer ghp_mock_pat_token_test_12345"

            assert REINGESTION_STATUS["status"] == "running"
            assert REINGESTION_STATUS["mode"] == "github_actions"
            assert REINGESTION_STATUS["last_run"] is not None

    def test_reingest_pdf_github_actions_dispatch_with_token(self, client, monkeypatch):
        """Verify GitHub Actions workflow dispatch when GITHUB_TOKEN is set."""
        monkeypatch.delenv("GITHUB_PAT", raising=False)
        monkeypatch.setenv("GITHUB_TOKEN", "ghs_mock_token_test_67890")

        mock_response = MagicMock()
        mock_response.status = 204
        mock_cm = MagicMock()
        mock_cm.__enter__.return_value = mock_response
        mock_cm.__exit__.return_value = None

        with patch("urllib.request.urlopen", return_value=mock_cm) as mock_urlopen, \
             patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            response = client.post("/api/admin/reingest-pdf", json={})

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["execution_mode"] == "github_actions"
            mock_ingest.assert_not_called()

            req = mock_urlopen.call_args[0][0]
            assert req.headers.get("Authorization") == "Bearer ghs_mock_token_test_67890"

    def test_reingest_pdf_fallback_to_background_tasks_on_dispatch_failure(self, client, monkeypatch):
        """Verify graceful fallback to background_tasks if GitHub Actions dispatch returns non-204."""
        monkeypatch.setenv("GITHUB_PAT", "ghp_mock_pat_token_test_12345")

        mock_response = MagicMock()
        mock_response.status = 500
        mock_cm = MagicMock()
        mock_cm.__enter__.return_value = mock_response
        mock_cm.__exit__.return_value = None

        with patch("urllib.request.urlopen", return_value=mock_cm), \
             patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            response = client.post("/api/admin/reingest-pdf", json={})

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["execution_mode"] == "background_tasks"
            mock_ingest.assert_called_once()
            assert REINGESTION_STATUS["mode"] == "background_tasks"


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 2. POST /api/admin/upload-pdf tests
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestUploadPdf:
    """Tests for POST /api/admin/upload-pdf endpoint non-blocking behavior."""

    def test_upload_pdf_non_blocking_background_task(self, client):
        """Verify PDF upload writes to Firebase Storage and schedules ingestion via BackgroundTasks."""
        mock_blob = MagicMock()
        mock_bucket = MagicMock()
        mock_bucket.name = "mathpulse-ai-2026.firebasestorage.app"
        mock_bucket.blob.return_value = mock_blob

        with patch("routes.admin_routes._init_firebase_storage", return_value=(None, mock_bucket)), \
             patch("routes.admin_routes.run_cloud_ingestion_and_upload") as mock_ingest:
            form_data = {
                "subjectId": "general_mathematics",
                "subjectName": "General Mathematics",
                "semester": 1,
                "quarter": 1,
            }
            files = {
                "file": ("SSHS_GM_Q1_Module1.pdf", b"%PDF-1.4 test module content binary stream", "application/pdf")
            }

            response = client.post("/api/admin/upload-pdf", data=form_data, files=files)

            assert response.status_code == 200
            data = response.json()
            assert data["success"] is True
            assert data["chunkCount"] == 0
            assert data["subjectId"] == "general_mathematics"
            expected_storage_path = "curriculum/general_mathematics/SSHS_GM_Q1_Module1.pdf"
            assert data["storageUrl"] == f"gs://mathpulse-ai-2026.firebasestorage.app/{expected_storage_path}"

            mock_bucket.blob.assert_called_once_with(expected_storage_path)
            mock_blob.upload_from_string.assert_called_once_with(
                b"%PDF-1.4 test module content binary stream",
                content_type="application/pdf",
            )
            mock_ingest.assert_called_once()

            assert expected_storage_path in PDF_METADATA
            assert PDF_METADATA[expected_storage_path] == {
                "subject": "General Mathematics",
                "subjectId": "general_mathematics",
                "type": "uploaded_module",
                "semester": 1,
                "quarter": 1,
            }

    def test_upload_pdf_rejects_non_pdf_file(self, client):
        """Verify rejection of non-PDF files with 400 Bad Request."""
        form_data = {
            "subjectId": "general_mathematics",
            "subjectName": "General Mathematics",
            "semester": 1,
            "quarter": 1,
        }
        files = {
            "file": ("curriculum_guide.docx", b"PK\x03\x04 mock docx data", "application/vnd.openxmlformats")
        }

        response = client.post("/api/admin/upload-pdf", data=form_data, files=files)
        assert response.status_code == 400
        assert "Only PDF files are allowed." in response.json()["detail"]

    def test_upload_pdf_storage_not_initialized(self, client):
        """Verify 500 error when Firebase storage is unavailable."""
        with patch("routes.admin_routes._init_firebase_storage", return_value=(None, None)):
            form_data = {
                "subjectId": "general_mathematics",
                "subjectName": "General Mathematics",
                "semester": 1,
                "quarter": 1,
            }
            files = {
                "file": ("test.pdf", b"%PDF-1.4 sample", "application/pdf")
            }
            response = client.post("/api/admin/upload-pdf", data=form_data, files=files)
            assert response.status_code == 500
            assert "Firebase storage is not initialized." in response.json()["detail"]


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 3. GET /api/admin/reingest-status tests
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestReingestStatus:
    """Tests for GET /api/admin/reingest-status endpoint."""

    def test_get_reingest_status_idle(self, client):
        """Verify initial/idle status is returned accurately."""
        response = client.get("/api/admin/reingest-status")
        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "idle"
        assert data["last_run"] is None
        assert data["message"] is None
        assert data["mode"] is None

    def test_get_reingest_status_reflects_active_reingestion(self, client):
        """Verify that updated reingestion status is reflected in GET response."""
        REINGESTION_STATUS["status"] = "running"
        REINGESTION_STATUS["mode"] = "github_actions"
        REINGESTION_STATUS["message"] = "Dispatched workflow to GitHub Actions"
        REINGESTION_STATUS["last_run"] = "2026-09-07T13:00:00+00:00"

        response = client.get("/api/admin/reingest-status")
        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "running"
        assert data["mode"] == "github_actions"
        assert data["message"] == "Dispatched workflow to GitHub Actions"
        assert data["last_run"] == "2026-09-07T13:00:00+00:00"


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 4. Authentication & RBAC Enforcement tests
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestAdminReingestAuth:
    """Tests role-based access control (RBAC) on admin reingestion endpoints."""

    def test_reingest_pdf_unauthorized(self):
        """Missing auth header returns 401."""
        unauth_client = TestClient(app)
        response = unauth_client.post("/api/admin/reingest-pdf", json={})
        assert response.status_code in {401, 403}

    def test_reingest_pdf_forbidden_for_student(self):
        """Student role returns 403 Forbidden."""
        main_module._firebase_ready = True
        main_module._init_firebase_admin = lambda: None
        if not main_module.firebase_auth:
            main_module.firebase_auth = MagicMock()
        mock_claims = {
            "uid": "student-uid",
            "email": "student@mathpulse.ai",
            "role": "student",
        }
        with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
            student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
            response = student_client.post("/api/admin/reingest-pdf", json={})
            assert response.status_code == 403

    def test_upload_pdf_unauthorized(self):
        """Upload without token returns 401."""
        unauth_client = TestClient(app)
        response = unauth_client.post("/api/admin/upload-pdf")
        assert response.status_code in {401, 403}

    def test_upload_pdf_forbidden_for_student(self):
        """Upload with student token returns 403."""
        main_module._firebase_ready = True
        main_module._init_firebase_admin = lambda: None
        if not main_module.firebase_auth:
            main_module.firebase_auth = MagicMock()
        mock_claims = {
            "uid": "student-uid",
            "email": "student@mathpulse.ai",
            "role": "student",
        }
        with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
            student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
            response = student_client.post("/api/admin/upload-pdf")
            assert response.status_code == 403

    def test_get_reingest_status_forbidden_for_student(self):
        """Status endpoint with student token returns 403."""
        main_module._firebase_ready = True
        main_module._init_firebase_admin = lambda: None
        if not main_module.firebase_auth:
            main_module.firebase_auth = MagicMock()
        mock_claims = {
            "uid": "student-uid",
            "email": "student@mathpulse.ai",
            "role": "student",
        }
        with patch.object(main_module.firebase_auth, "verify_id_token", return_value=mock_claims):
            student_client = TestClient(app, headers={"Authorization": "Bearer student-token"})
            response = student_client.get("/api/admin/reingest-status")
            assert response.status_code == 403