Skip to content

Commit 25983ef

Browse files
committed
test: add live coverage manifest
1 parent d19cb1b commit 25983ef

6 files changed

Lines changed: 239 additions & 8 deletions

File tree

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -454,14 +454,20 @@ TEST_ACCOUNTS_URL="https://example.com/accounts" python3.13 -m pytest tests/live
454454
GitHub Actions also has a scheduled **Live Tests** workflow that runs nightly
455455
and can be launched manually. It starts the Docker Compose API service, creates
456456
a real session from `TEST_ACCOUNTS_URL`, sends it through `X-Session-ID`, and
457-
checks `/user/about`, `/media/comments`, and paginated read-list routes. A
458-
second nightly job runs the same HTTP smoke against the published Docker image
457+
checks `/user/about`, `/media/comments`, `/account/family`,
458+
`/user/featured/accounts`, `/user/fundraiser`, and paginated read-list routes.
459+
A second nightly job runs the same HTTP smoke against the published Docker image
459460
`subzeroid/aiograpi-rest:latest`, so the public `docker run` path is exercised
460-
with a real session too. Both the direct ASGI live smoke and the published
461-
image HTTP smoke upload a real JPEG to `/story/upload`, verify the created
462-
story through `/story`, `/user/stories`, and `/story/viewers`, download the
463-
media through `/story/download`, validate that it is an image, and delete the
464-
story.
461+
with a real session too.
462+
463+
Every public OpenAPI operation is classified by the live coverage manifest as a
464+
read, download, session, reversible mutation, upload, cleanup, or guarded
465+
operation. Non-guarded mutations must name the read-back or cleanup endpoint
466+
that proves the Instagram state changed. Both the direct ASGI live smoke and
467+
the published image HTTP smoke upload a real JPEG to `/story/upload`, verify
468+
the created story through `/story`, `/user/stories`, and `/story/viewers`,
469+
download the media through `/story/download`, validate that it is an image, and
470+
delete the story.
465471

466472
Generate and validate docs:
467473

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "aiograpi-rest"
7-
version = "5.15.0"
7+
version = "5.15.1"
88
description = "RESTful API service for aiograpi"
99
readme = "README.md"
1010
requires-python = ">=3.13"

tests/live/coverage_manifest.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass(frozen=True)
7+
class LivePolicy:
8+
kind: str
9+
reason: str = ""
10+
verify_with: str = ""
11+
12+
13+
SYSTEM_PATHS = {
14+
"/health",
15+
"/ready",
16+
"/metrics",
17+
"/build",
18+
"/deps",
19+
}
20+
21+
SESSION_MUTATIONS = {
22+
("POST", "/auth/login"): "/account",
23+
("POST", "/auth/login/by/sessionid"): "/account",
24+
("PATCH", "/auth/relogin"): "/account",
25+
("PATCH", "/auth/settings"): "/auth/settings",
26+
}
27+
28+
REVERSIBLE_MUTATIONS = {
29+
("POST", "/hashtag/follow"): "DELETE /hashtag/follow",
30+
("DELETE", "/hashtag/follow"): "GET /hashtag",
31+
("POST", "/media/like"): "DELETE /media/like",
32+
("DELETE", "/media/like"): "GET /media",
33+
("PATCH", "/media/seen"): "GET /media",
34+
("POST", "/media/comment"): "GET /media/comments",
35+
("DELETE", "/media/comment"): "GET /media/comments",
36+
("POST", "/media/comment/check/offensive"): "same response",
37+
("POST", "/media/comment/like"): "DELETE /media/comment/like",
38+
("DELETE", "/media/comment/like"): "GET /media/comment/likers",
39+
("POST", "/media/save"): "DELETE /media/save",
40+
("DELETE", "/media/save"): "GET /account/liked/media",
41+
("POST", "/note"): "GET /notes",
42+
("DELETE", "/note"): "GET /notes",
43+
("POST", "/note/music"): "GET /notes",
44+
("PATCH", "/notes/last-seen"): "GET /notes",
45+
("POST", "/story/like"): "DELETE /story/like",
46+
("DELETE", "/story/like"): "GET /story",
47+
("PATCH", "/story/seen"): "GET /story",
48+
("POST", "/user/follow"): "GET /user/friendship",
49+
("DELETE", "/user/follow"): "GET /user/friendship",
50+
("DELETE", "/user/follower"): "GET /user/friendship",
51+
("POST", "/user/close-friend"): "GET /user/friendship",
52+
("DELETE", "/user/close-friend"): "GET /user/friendship",
53+
("POST", "/user/mute/posts"): "GET /user/friendship",
54+
("DELETE", "/user/mute/posts"): "GET /user/friendship",
55+
("POST", "/user/mute/stories"): "GET /user/friendship",
56+
("DELETE", "/user/mute/stories"): "GET /user/friendship",
57+
("POST", "/user/notifications/posts"): "GET /user/friendship",
58+
("DELETE", "/user/notifications/posts"): "GET /user/friendship",
59+
("POST", "/user/notifications/stories"): "GET /user/friendship",
60+
("DELETE", "/user/notifications/stories"): "GET /user/friendship",
61+
("POST", "/user/notifications/reels"): "GET /user/friendship",
62+
("DELETE", "/user/notifications/reels"): "GET /user/friendship",
63+
("POST", "/user/notifications/videos"): "GET /user/friendship",
64+
("DELETE", "/user/notifications/videos"): "GET /user/friendship",
65+
("POST", "/user/block"): "GET /user/friendship",
66+
("DELETE", "/user/block"): "GET /user/friendship",
67+
}
68+
69+
UPLOAD_MUTATIONS = {
70+
("POST", "/album/upload"): "GET /media",
71+
("POST", "/album/upload/with/music"): "GET /media",
72+
("POST", "/clip/upload"): "GET /media",
73+
("POST", "/clip/upload/by/url"): "GET /media",
74+
("POST", "/clip/upload/with/music"): "GET /media",
75+
("POST", "/igtv/upload"): "GET /media",
76+
("POST", "/igtv/upload/by/url"): "GET /media",
77+
("POST", "/photo/upload"): "GET /media",
78+
("POST", "/photo/upload/by/url"): "GET /media",
79+
("POST", "/photo/upload/with/music"): "GET /media",
80+
("POST", "/story/upload"): "GET /story + GET /user/stories + GET /story/download",
81+
("POST", "/story/upload/by/url"): "GET /story + GET /user/stories + GET /story/download",
82+
("POST", "/video/upload"): "GET /media",
83+
("POST", "/video/upload/by/url"): "GET /media",
84+
}
85+
86+
GUARDED_PREFIX_REASONS = {
87+
("/account", "account mutation"): "changes authenticated account state or depends on inbound follow requests",
88+
("/auth/challenge", "challenge"): "requires a real active Instagram challenge",
89+
("/auth/totp", "totp state"): "changes two-factor authentication state",
90+
("/clip/pin", "owned reel"): "requires owned Reel fixture and profile cleanup",
91+
("/direct", "direct fixture"): "requires controlled Direct threads, users, and messages",
92+
("/highlight", "highlight fixture"): "requires owned story/highlight fixtures",
93+
("/media/archive", "owned media"): "requires owned media and archive state cleanup",
94+
("/media/livestream", "live broadcast"): "creates or mutates a real livestream",
95+
("/media/pin", "owned media"): "requires owned media and visible profile cleanup",
96+
("/media/comment/pin", "owned comment"): "requires owned media comments",
97+
("/notifications", "account settings"): "changes notification settings on the authenticated account",
98+
}
99+
100+
GUARDED_EXACT_REASONS = {
101+
("DELETE", "/media"): "deletes real media and requires a dedicated upload fixture",
102+
("PATCH", "/media"): "edits real media caption and requires a dedicated upload fixture",
103+
}
104+
105+
106+
def operation_policy(method: str, path: str) -> LivePolicy:
107+
method = method.upper()
108+
109+
if path in SYSTEM_PATHS:
110+
return LivePolicy("system")
111+
if method == "GET" and path.endswith(("/download", "/download/by/url", "/download/by/urls")):
112+
return LivePolicy("download", verify_with="binary/media validation")
113+
if method == "GET":
114+
if path.startswith("/auth/"):
115+
return LivePolicy("session-read")
116+
return LivePolicy("read")
117+
118+
if (method, path) in SESSION_MUTATIONS:
119+
return LivePolicy("session", verify_with=SESSION_MUTATIONS[(method, path)])
120+
if (method, path) in REVERSIBLE_MUTATIONS:
121+
return LivePolicy("reversible", verify_with=REVERSIBLE_MUTATIONS[(method, path)])
122+
if (method, path) in UPLOAD_MUTATIONS:
123+
return LivePolicy("upload", verify_with=UPLOAD_MUTATIONS[(method, path)])
124+
if (method, path) == ("DELETE", "/story"):
125+
return LivePolicy("cleanup", verify_with="GET /story returns 404 or missing story")
126+
127+
guarded_reason = GUARDED_EXACT_REASONS.get((method, path))
128+
if guarded_reason:
129+
return LivePolicy("guarded", reason=guarded_reason)
130+
for prefix, reason in GUARDED_PREFIX_REASONS:
131+
if path.startswith(prefix):
132+
return LivePolicy("guarded", reason=reason)
133+
134+
return LivePolicy("unclassified")
135+
136+
137+
def guarded_operations() -> dict[tuple[str, str], LivePolicy]:
138+
from aiograpi_rest.main import app
139+
140+
return {
141+
(method.upper(), path): policy
142+
for path, methods in app.openapi()["paths"].items()
143+
for method in methods
144+
if (policy := operation_policy(method, path)).kind == "guarded"
145+
}

tests/live/test_live_http_smoke.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import ssl
55
import time
6+
import urllib.error
67
import urllib.parse
78
import urllib.request
89
from io import BytesIO
@@ -44,6 +45,18 @@ def _request_json(base_url, method, path, *, headers=None, data=None):
4445
return json.loads(payload)
4546

4647

48+
def _request_json_result(base_url, method, path, *, headers=None, data=None):
49+
try:
50+
return 200, _request_json(base_url, method, path, headers=headers, data=data)
51+
except urllib.error.HTTPError as exc:
52+
payload = exc.read()
53+
try:
54+
body = json.loads(payload)
55+
except json.JSONDecodeError:
56+
body = payload.decode(errors="replace")
57+
return exc.code, body
58+
59+
4760
def _request_bytes(base_url, method, path, *, headers=None):
4861
req = urllib.request.Request(
4962
base_url.rstrip("/") + path,
@@ -183,6 +196,28 @@ def _assert_published_http_pagination(base_url, headers, public_user_id):
183196
)
184197

185198

199+
def _assert_published_http_v515_discovery(base_url, headers, public_user_id):
200+
family = _request_json(base_url, "GET", "/account/family", headers=headers)
201+
assert isinstance(family, dict), f"Unexpected /account/family response: {family!r}"
202+
203+
featured = _request_json(
204+
base_url,
205+
"GET",
206+
f"/user/featured/accounts?user_id={public_user_id}",
207+
headers=headers,
208+
)
209+
assert isinstance(featured, dict), f"Unexpected /user/featured/accounts response: {featured!r}"
210+
211+
status, fundraiser = _request_json_result(
212+
base_url,
213+
"GET",
214+
f"/user/fundraiser?user_id={public_user_id}",
215+
headers=headers,
216+
)
217+
assert status in {200, 400, 404}, f"Unexpected /user/fundraiser status {status}: {fundraiser!r}"
218+
assert isinstance(fundraiser, dict), f"Unexpected /user/fundraiser response: {fundraiser!r}"
219+
220+
186221
def test_live_http_login_authorize_and_user_about_flow():
187222
accounts_url = os.environ.get("TEST_ACCOUNTS_URL")
188223
if not accounts_url:
@@ -221,6 +256,7 @@ def test_live_http_login_authorize_and_user_about_flow():
221256
assert "is_verified" in about
222257
assert isinstance(about["former_usernames"], str)
223258
_assert_published_http_pagination(base_url, headers, user["pk"])
259+
_assert_published_http_v515_discovery(base_url, headers, user["pk"])
224260
return
225261
except Exception as exc:
226262
errors.append(f"{account.get('username', '?')}: {type(exc).__name__}: {exc}")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from aiograpi_rest.main import app
2+
from tests.live.coverage_manifest import guarded_operations, operation_policy
3+
4+
5+
def _openapi_operations() -> set[tuple[str, str]]:
6+
return {
7+
(method.upper(), path)
8+
for path, methods in app.openapi()["paths"].items()
9+
for method in methods
10+
}
11+
12+
13+
def test_live_coverage_manifest_classifies_every_openapi_operation():
14+
missing = [
15+
f"{method} {path}"
16+
for method, path in sorted(_openapi_operations())
17+
if operation_policy(method, path).kind == "unclassified"
18+
]
19+
20+
assert missing == []
21+
22+
23+
def test_live_coverage_manifest_requires_post_mutation_verification():
24+
missing_verification = []
25+
for method, path in sorted(_openapi_operations()):
26+
policy = operation_policy(method, path)
27+
if method in {"POST", "PATCH", "DELETE"} and policy.kind != "guarded":
28+
if not policy.verify_with:
29+
missing_verification.append(f"{method} {path}")
30+
31+
assert missing_verification == []
32+
33+
34+
def test_live_coverage_manifest_documents_guarded_operations():
35+
guarded = guarded_operations()
36+
37+
assert ("PATCH", "/account/password") in guarded
38+
assert ("POST", "/auth/challenge/resolve") in guarded
39+
assert all(policy.reason for policy in guarded.values())

tests/test_project_metadata.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,9 @@ def test_live_tests_cover_published_image_and_paginated_read_lists():
650650
for endpoint in (
651651
"/user/posts",
652652
"/media/comments",
653+
"/account/family",
654+
"/user/featured/accounts",
655+
"/user/fundraiser",
653656
"/hashtag/media/top",
654657
"/direct/inbox",
655658
"/story/upload",
@@ -659,6 +662,8 @@ def test_live_tests_cover_published_image_and_paginated_read_lists():
659662
assert endpoint in http_smoke
660663

661664
assert "published Docker image" in readme
665+
assert "Every public OpenAPI operation is classified by the live coverage manifest" in readme
666+
assert "Non-guarded mutations must name the read-back or cleanup endpoint" in readme
662667
assert "paginated read-list routes" in readme
663668

664669

0 commit comments

Comments
 (0)