File size: 12,311 Bytes
0e84a1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import hashlib, hmac, json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
from app.db import init_db
from app.models import PageContext, Channel, RiskLevel
from app.services.event_normalizer import normalize_meta_payload
from app.services.risk_rules import classify_risk, requires_human_approval

init_db()
client = TestClient(app)

def sig(body: bytes, secret="example-secret") -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()

def test_healthz():
    assert client.get("/healthz").json() == {"status": "ok"}

def test_webhook_verify():
    r = client.get("/meta/webhook", params={"hub.mode":"subscribe","hub.verify_token":"verify-test","hub.challenge":"12345"})
    assert r.status_code == 200
    assert r.json() == 12345

def test_webhook_rejects_missing_signature():
    r = client.post("/meta/webhook", json={"entry":[]})
    assert r.status_code == 403

def test_webhook_accepts_valid_signature_and_stores():
    body = json.dumps({"entry":[{"id":"page-healthcare","messaging":[{"sender":{"id":"u1"},"recipient":{"id":"page-healthcare"},"message":{"mid":"m1","text":"hello"}}]}]}, separators=(",", ":")).encode()
    r = client.post("/meta/webhook", content=body, headers={"X-Hub-Signature-256": sig(body), "Content-Type":"application/json"})
    assert r.status_code == 200
    assert r.json()["stored_event_ids"]

def test_unknown_page_fails_closed():
    try:
        normalize_meta_payload({"entry":[{"id":"unknown","messaging":[]}]})
    except ValueError as e:
        assert "Unrecognized" in str(e)
    else:
        raise AssertionError("unknown page accepted")

def test_internal_auth_rejection():
    r = client.post("/tools/meta/draft", json={})
    assert r.status_code == 401

def test_ccm_clinical_risk_high():
    risk = classify_risk(PageContext.healthcare, Channel.messenger, "Should I change my medication dose?", False)
    assert risk == RiskLevel.high
    assert requires_human_approval(PageContext.healthcare, Channel.messenger, risk, False)

def test_civic_public_comment_requires_approval():
    risk = classify_risk(PageContext.civic, Channel.comment, "Where do I vote?", True)
    assert risk == RiskLevel.high
    assert requires_human_approval(PageContext.civic, Channel.comment, risk, True)

def test_page_post_media_required_refuses_missing_media():
    r = client.post("/tools/meta/draft", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "channel": "page_post",
        "draft_text": "Approved civic card caption",
        "media_required": True,
        "risk_level": "medium",
    })
    assert r.status_code == 400
    assert "media_required" in r.json()["detail"]

def test_page_post_draft_accepts_local_png_media(tmp_path: Path):
    png = tmp_path / "card.png"
    png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x04\xb0\x00\x00\x04\xb0" + b"\x08\x02\x00\x00\x00" + b"\x00\x00\x00\x00")
    r = client.post("/tools/meta/draft", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "channel": "page_post",
        "draft_text": "Approved civic card caption",
        "media_required": True,
        "media_attachments": [{"media_path": str(png), "media_type": "image", "alt_text": "Civic card"}],
        "risk_level": "medium",
    })
    assert r.status_code == 200
    assert r.json()["status"] == "needs_review"

def test_reels_validation_refuses_non_video_file(tmp_path: Path):
    not_video = tmp_path / "card.png"
    not_video.write_bytes(b"not a video")
    r = client.post("/tools/meta/reels/publish", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "video_path": str(not_video),
        "description": "test",
        "approved_by": "test",
        "validation_only": True,
    })
    assert r.status_code == 400
    assert "unsupported Reels video type" in r.json()["detail"]

def test_read_only_page_posts_endpoint(monkeypatch):
    from app.services.meta_client import MetaClient

    calls = {}

    async def fake_list_page_posts(self, page_context, edge="posts", limit=10, after=None, before=None):
        calls.update({"page_context": page_context.value, "edge": edge, "limit": limit, "after": after, "before": before})
        return {"data": [{"id": "post-1", "message": "hello"}], "paging": {"cursors": {"after": "next"}}}

    monkeypatch.setattr(MetaClient, "list_page_posts", fake_list_page_posts)
    r = client.get(
        "/tools/meta/page/posts",
        headers={"X-Meta-Bridge-Key": "internal-test"},
        params={"page_context": "civic", "edge": "published_posts", "limit": 5, "after": "cursor-a"},
    )
    assert r.status_code == 200
    assert r.json()["data"][0]["id"] == "post-1"
    assert calls == {"page_context": "civic", "edge": "published_posts", "limit": 5, "after": "cursor-a", "before": None}

def test_read_only_insights_rejects_unverified_metric():
    r = client.get(
        "/tools/meta/insights",
        headers={"X-Meta-Bridge-Key": "internal-test"},
        params={"page_context": "civic", "metrics": "page_fans"},
    )
    assert r.status_code == 400
    assert "page_fans" in r.json()["detail"]["invalid_metrics"]

def test_read_only_media_endpoint_can_return_all_edges(monkeypatch):
    from app.services.meta_client import MetaClient

    async def fake_list_page_media(self, page_context, media_type="all", limit=10, after=None, before=None):
        return {"media": {"photos": {"data": []}, "videos": {"data": []}, "video_reels": {"data": []}}}

    monkeypatch.setattr(MetaClient, "list_page_media", fake_list_page_media)
    r = client.get(
        "/tools/meta/page/media",
        headers={"X-Meta-Bridge-Key": "internal-test"},
        params={"page_context": "healthcare", "media_type": "all"},
    )
    assert r.status_code == 200
    assert sorted(r.json()["media"]) == ["photos", "video_reels", "videos"]

def test_read_only_endpoints_require_internal_auth():
    r = client.get("/tools/meta/page/scheduled_posts", params={"page_context": "civic"})
    assert r.status_code == 401

def test_page_post_validation_only_returns_payload_without_publish():
    scheduled = (datetime.now(timezone.utc) + timedelta(minutes=20)).isoformat()
    r = client.post("/tools/meta/page_post/publish", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "message": "Validation only caption",
        "approved_by": "test",
        "publish_mode": "validation_only",
        "scheduled_publish_time": scheduled,
    })
    assert r.status_code == 200
    body = r.json()
    assert body["status"] == "validated"
    assert body["meta_response"]["publish_attempted"] is False
    assert body["meta_response"]["payload"]["message"] == "Validation only caption"
    assert body["meta_response"]["payload"]["published"] == "false"
    assert "scheduled_publish_time" in body["meta_response"]["payload"]

def test_scheduled_page_post_payload_has_no_immediate_publish_flag():
    from app.services.meta_client import MetaClient

    scheduled = datetime.now(timezone.utc) + timedelta(minutes=20)
    payload = MetaClient().build_page_post_payload("Scheduled caption", publish_mode="scheduled", scheduled_publish_time=scheduled)
    assert payload["message"] == "Scheduled caption"
    assert payload["published"] == "false"
    assert int(payload["scheduled_publish_time"]) >= int(scheduled.timestamp()) - 1

def test_draft_detail_edit_history_and_validation_publish():
    create = client.post("/tools/meta/draft", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "channel": "page_post",
        "draft_text": "Draft lifecycle caption",
        "risk_level": "medium",
        "publish_mode": "validation_only",
        "created_by": "test",
    })
    assert create.status_code == 200
    draft_id = create.json()["draft_id"]

    detail = client.get(f"/approvals/drafts/{draft_id}", headers={"X-Meta-Bridge-Key":"internal-test"})
    assert detail.status_code == 200
    assert detail.json()["publish_mode"] == "validation_only"

    edit = client.post(f"/approvals/drafts/{draft_id}/edit", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "edited_by": "test-editor",
        "draft_text": "Edited lifecycle caption",
    })
    assert edit.status_code == 200
    assert edit.json()["draft_text"] == "Edited lifecycle caption"

    approve = client.post(f"/approvals/drafts/{draft_id}/approve", headers={"X-Meta-Bridge-Key":"internal-test"}, json={"approved_by": "test-approver"})
    assert approve.status_code == 200
    publish = client.post(f"/approvals/drafts/{draft_id}/publish", headers={"X-Meta-Bridge-Key":"internal-test"})
    assert publish.status_code == 200
    assert publish.json()["meta_response"]["publish_attempted"] is False

    history = client.get(f"/approvals/drafts/{draft_id}/history", headers={"X-Meta-Bridge-Key":"internal-test"})
    assert history.status_code == 200
    actions = {row["action"] for row in history.json()}
    assert {"draft.created", "draft.edited", "draft.approved", "draft.validated"} <= actions

def test_comment_thread_read_endpoint(monkeypatch):
    from app.services.meta_client import MetaClient

    async def fake_read_comment_thread(self, page_context, object_id, limit=10, after=None, before=None):
        return {"data": [{"id": "comment-1", "message": "test"}], "paging": {"cursors": {"after": "next"}}}

    monkeypatch.setattr(MetaClient, "read_comment_thread", fake_read_comment_thread)
    r = client.get(
        "/tools/meta/comment/thread",
        headers={"X-Meta-Bridge-Key":"internal-test"},
        params={"page_context": "civic", "object_id": "post-1", "limit": 5},
    )
    assert r.status_code == 200
    assert r.json()["data"][0]["id"] == "comment-1"

def test_comment_moderation_requires_approval_reason():
    r = client.post("/tools/meta/comment/moderate", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "comment_id": "comment-1",
        "action": "hide",
        "approved_by": "",
        "reason": "",
    })
    assert r.status_code == 403

def test_comment_moderation_calls_meta_with_audit(monkeypatch):
    from app.services.meta_client import MetaClient

    async def fake_moderate_comment(self, page_context, comment_id, action):
        return {"success": True, "comment_id": comment_id, "action": action}

    monkeypatch.setattr(MetaClient, "moderate_comment", fake_moderate_comment)
    r = client.post("/tools/meta/comment/moderate", headers={"X-Meta-Bridge-Key":"internal-test"}, json={
        "page_context": "civic",
        "comment_id": "comment-1",
        "action": "hide",
        "approved_by": "test",
        "reason": "policy test",
    })
    assert r.status_code == 200
    assert r.json()["status"] == "hide"
    assert r.json()["meta_response"]["success"] is True

def test_messenger_conversation_and_response_window_endpoints(monkeypatch):
    from app.services.meta_client import MetaClient

    async def fake_read_conversation_detail(self, page_context, conversation_id, limit=10, after=None, before=None):
        return {"id": conversation_id, "messages": {"data": [{"id": "m1", "created_time": "2026-05-24T18:00:00+0000"}]}}

    async def fake_response_window(self, page_context, conversation_id):
        return {"conversation_id": conversation_id, "response_window_status": "inspect_latest_message_time"}

    monkeypatch.setattr(MetaClient, "read_conversation_detail", fake_read_conversation_detail)
    monkeypatch.setattr(MetaClient, "messenger_response_window", fake_response_window)
    detail = client.get("/tools/meta/messenger/conversation", headers={"X-Meta-Bridge-Key":"internal-test"}, params={"page_context":"healthcare", "conversation_id":"t_1"})
    assert detail.status_code == 200
    assert detail.json()["id"] == "t_1"
    window = client.get("/tools/meta/messenger/response_window", headers={"X-Meta-Bridge-Key":"internal-test"}, params={"page_context":"healthcare", "conversation_id":"t_1"})
    assert window.status_code == 200
    assert window.json()["response_window_status"] == "inspect_latest_message_time"