| import json |
| import numpy as np |
| import pytest |
| from fastapi.testclient import TestClient |
| from pathlib import Path |
|
|
| import astroparse_api.main as main_mod |
| from astroparse_api.main import app |
| from astroparse_api.annotate import CompletionCache |
|
|
| FIXTURE = Path(__file__).parent / "fixtures" / "mowla_iyer_2024.pdf" |
|
|
|
|
| class FakeCorpus: |
| class _DS: |
| def search(self, col, q, k): |
| class R: |
| indices = [0] |
| scores = np.array([0.5]) |
| return R() |
|
|
| def __getitem__(self, idx): |
| import datetime |
| return { |
| "title": ["T"], |
| "abstract": ["A"], |
| "authors": [["Iyer, K."]], |
| "date": [datetime.date(2022, 1, 1)], |
| "keywords": [["galaxy"]], |
| "bibcode": ["2022ApJ...1I"], |
| "arxiv_id": ["2201.00001"], |
| } |
|
|
| ds = _DS() |
|
|
| @property |
| def arxiv_ids(self) -> set: |
| return {"2201.00001"} |
|
|
| @property |
| def bibcodes(self) -> set: |
| return {"2022ApJ...1I"} |
|
|
| def search(self, emb, k=1000): |
| return [0], np.array([2.0]) |
|
|
|
|
| @pytest.fixture |
| def client(monkeypatch, tmp_path): |
| monkeypatch.setenv("ASTROPARSE_SKIP_CORPUS", "1") |
| monkeypatch.setenv("ASTROPARSE_FIGURES_DIR", str(tmp_path / "figures")) |
| monkeypatch.setattr(main_mod, "get_corpus", lambda: FakeCorpus()) |
| monkeypatch.setattr( |
| main_mod, |
| "embed_batch", |
| lambda texts, api_key: [np.zeros(2, dtype=np.float32) for _ in texts], |
| ) |
| main_mod.cache = CompletionCache(tmp_path / "cache.sqlite") |
| with TestClient(app) as c: |
| yield c |
|
|
|
|
| def _collect_sse(resp): |
| events, cur = [], {} |
| for line in resp.iter_lines(): |
| if line.startswith("event:"): |
| cur["event"] = line.split(":", 1)[1].strip() |
| elif line.startswith("data:"): |
| cur["data"] = json.loads(line.split(":", 1)[1]) |
| events.append(cur) |
| cur = {} |
| return events |
|
|
|
|
| def test_parse_job_flow(client): |
| r = client.post( |
| "/api/parse", |
| files={"file": ("m.pdf", FIXTURE.read_bytes(), "application/pdf")}, |
| ) |
| assert r.status_code == 202 |
| job_id = r.json()["jobId"] |
| with client.stream("GET", f"/api/jobs/{job_id}/events") as resp: |
| events = _collect_sse(resp) |
| names = [e["event"] for e in events] |
| assert names.count("result") == 1 |
| stage_events = [e for e in events if e["event"] == "stage"] |
| assert {s["data"]["index"] for s in stage_events} == set(range(7)) |
| result = next(e for e in events if e["event"] == "result")["data"] |
| assert result["paper"]["paragraphs"] and result["litByPara"] |
| assert "pdfHash" in result |
| assert "figures" in result |
| assert "litByFig" in result |
|
|
|
|
| def test_annotate_cached_round_trip(client, monkeypatch): |
| async def fake_stream(provider, model, prompt, key): |
| for chunk in ["A fine ", "note."]: |
| yield chunk |
|
|
| monkeypatch.setattr(main_mod, "stream_completion", fake_stream) |
| body = { |
| "paragraphId": "p1", |
| "paragraph": "text", |
| "section": "S", |
| "mode": "referee", |
| "lit": [], |
| "provider": "anthropic", |
| "model": "claude-haiku-4-5", |
| "key": "sk-x", |
| } |
| with client.stream("POST", "/api/annotate", json=body) as resp: |
| events = _collect_sse(resp) |
| done = next(e for e in events if e["event"] == "done")["data"] |
| assert done["text"] == "A fine note." and done["cached"] is False |
|
|
| with client.stream("POST", "/api/annotate", json=body) as resp: |
| events2 = _collect_sse(resp) |
| done2 = next(e for e in events2 if e["event"] == "done")["data"] |
| assert done2["cached"] is True and done2["text"] == "A fine note." |
|
|
|
|
| def test_annotate_missing_key(client): |
| body = { |
| "paragraphId": "p1", |
| "paragraph": "t", |
| "section": "S", |
| "mode": "referee", |
| "lit": [], |
| "provider": "anthropic", |
| "model": "m", |
| "key": "", |
| } |
| with client.stream("POST", "/api/annotate", json=body) as resp: |
| events = _collect_sse(resp) |
| assert events[-1]["event"] == "error" |
| assert "no API key set" in events[-1]["data"]["message"] |
|
|
|
|
| def test_figures_endpoint_serves_png(client, tmp_path, monkeypatch): |
| """After a parse, the figure PNG can be fetched from the figures endpoint.""" |
| |
| r = client.post( |
| "/api/parse", |
| files={"file": ("m.pdf", FIXTURE.read_bytes(), "application/pdf")}, |
| ) |
| job_id = r.json()["jobId"] |
| with client.stream("GET", f"/api/jobs/{job_id}/events") as resp: |
| events = _collect_sse(resp) |
| result = next(e for e in events if e["event"] == "result")["data"] |
| pdf_hash = result["pdfHash"] |
| figures = result["figures"] |
|
|
| |
| with_image = [f for f in figures if f["hasImage"]] |
| assert with_image, "Expected at least one figure with an image" |
| fig_id = with_image[0]["id"] |
|
|
| resp = client.get(f"/api/figures/{pdf_hash}/{fig_id}.png") |
| assert resp.status_code == 200 |
| assert resp.headers["content-type"] == "image/png" |
| assert len(resp.content) > 5_000 |
|
|
|
|
| def test_figures_endpoint_invalid_hash(client): |
| """An invalid (non-hex-64) hash returns 404.""" |
| r = client.get("/api/figures/invalid_hash/f1.png") |
| assert r.status_code == 404 |
|
|
|
|
| def test_figures_endpoint_invalid_fig_id(client): |
| """An invalid fig_id like 'fx1' returns 404.""" |
| good_hash = "a" * 64 |
| r = client.get(f"/api/figures/{good_hash}/fx1.png") |
| assert r.status_code == 404 |
|
|
|
|
| def test_figures_endpoint_traversal_attempt(client): |
| """Traversal payloads in either path segment are rejected by validation.""" |
| good_hash = "a" * 64 |
| |
| r = client.get(f"/api/figures/..%2f..%2f{good_hash[:58]}/f1.png") |
| assert r.status_code == 404 |
| |
| r = client.get(f"/api/figures/{good_hash}/..%2f..%2fsecret.png") |
| assert r.status_code == 404 |
| |
| r = client.get(f"/api/figures/{good_hash}/f1.png") |
| assert r.status_code == 404 |
|
|
|
|
| def test_jobs_stage_count(): |
| """STAGES list must have exactly 7 entries.""" |
| from astroparse_api.jobs import STAGES |
| assert len(STAGES) == 7 |
| assert STAGES[6] == "Tipping in the plates" |
|
|
|
|
| |
| |
| |
|
|
| _MD_UPLOAD = ( |
| "# Test Manuscript\n\n" |
| "## Introduction\n\n" |
| + ("Galaxy formation and evolution in the early universe is a key topic. " * 20) |
| + "\n\n" |
| + ("Star formation rates and quenching mechanisms drive cosmic evolution. " * 20) |
| + "\n\n" |
| "## References\n\n" |
| "[1] Someone et al. 2020\n" |
| ).encode() |
|
|
|
|
| def test_markdown_upload_all_stages_emitted(client): |
| """Uploading a .md file triggers all 7 stages and returns figures=[].""" |
| r = client.post( |
| "/api/parse", |
| files={"file": ("notes.md", _MD_UPLOAD, "text/markdown")}, |
| ) |
| assert r.status_code == 202 |
| job_id = r.json()["jobId"] |
| with client.stream("GET", f"/api/jobs/{job_id}/events") as resp: |
| events = _collect_sse(resp) |
| |
| stage_events = [e for e in events if e["event"] == "stage"] |
| assert {s["data"]["index"] for s in stage_events} == set(range(7)), ( |
| f"Missing stages: got indices {[s['data']['index'] for s in stage_events]}" |
| ) |
| |
| result = next(e for e in events if e["event"] == "result")["data"] |
| assert result["paper"]["paragraphs"], "Expected paragraphs in result" |
| assert result["figures"] == [], f"Expected figures=[], got {result['figures']}" |
| assert result["litByPara"], "Expected litByPara non-empty" |
| assert result["litByFig"] == {}, f"Expected litByFig={{}}, got {result['litByFig']}" |
|
|
|
|
| def test_annotate_network_error_sanitized(client, monkeypatch): |
| """Non-ProviderError failures (e.g. httpx ConnectError) emit a sanitized |
| error event — never the raw exception, which can carry a key-bearing URL.""" |
| async def broken_stream(provider, model, prompt, key): |
| raise RuntimeError(f"https://example/?key={key}") |
| yield |
| monkeypatch.setattr(main_mod, "stream_completion", broken_stream) |
| body = { |
| "paragraphId": "p1", "paragraph": "t", "section": "S", "mode": "referee", |
| "lit": [], "provider": "gemini", "model": "m", "key": "SECRETKEY", |
| } |
| with client.stream("POST", "/api/annotate", json=body) as resp: |
| events = _collect_sse(resp) |
| assert events[-1]["event"] == "error" |
| msg = events[-1]["data"]["message"] |
| assert "SECRETKEY" not in msg and "could not reach gemini" in msg |
|
|