229 lines
8.2 KiB
Python
229 lines
8.2 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status: int, body):
|
|
self.status = status
|
|
self.body = body if isinstance(body, str) else json.dumps(body)
|
|
self.headers = {}
|
|
|
|
async def text(self):
|
|
return self.body
|
|
|
|
async def json(self):
|
|
return json.loads(self.body)
|
|
|
|
|
|
class FakeFetcher:
|
|
def __init__(self, routes: dict):
|
|
self.routes = routes
|
|
self.requests: list[str] = []
|
|
self.session = None
|
|
self.posts: list[tuple] = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return None
|
|
|
|
@staticmethod
|
|
def key(url: str, params: dict | None) -> str:
|
|
parts = urlsplit(url)
|
|
query = dict(parse_qsl(parts.query))
|
|
query.update({name: str(value) for name, value in (params or {}).items()})
|
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(sorted(query.items())), ""))
|
|
|
|
async def get(self, url, params=None, timeout=None, **kwargs):
|
|
key = self.key(url, params)
|
|
self.requests.append(key)
|
|
route = self.routes.get(key)
|
|
if route is None:
|
|
return FakeResponse(404, "not found")
|
|
if callable(route):
|
|
route = route()
|
|
status, body = route if isinstance(route, tuple) else (200, route)
|
|
return FakeResponse(status, body)
|
|
|
|
async def post(self, url, data=None, timeout=None, **kwargs):
|
|
self.posts.append((url, dict(data or {})))
|
|
route = self.routes.get("post:" + url)
|
|
if callable(route):
|
|
route = route(dict(data or {}))
|
|
status, body = route if isinstance(route, tuple) else (200, route or "")
|
|
return FakeResponse(status, body)
|
|
|
|
async def render_html(self, url, session=None):
|
|
route = self.routes.get("render:" + url)
|
|
if route is None:
|
|
response = await self.get(url)
|
|
return response.status, response.body
|
|
return 200, route
|
|
|
|
def page(self, session=None):
|
|
return FakePage(self)
|
|
|
|
async def render(self, url, collect_links=False):
|
|
response = await self.get(url)
|
|
return {"status": response.status, "text": response.body, "links": [], "rendered": False}
|
|
|
|
|
|
def json_post(number: int) -> dict:
|
|
return {
|
|
"id": 1000 + number,
|
|
"title": f"Post number {number} about asyncio",
|
|
"content_html": f"<p>This is the body of post {number}, long enough to sync.</p>",
|
|
"created_at": f"2026-10-{number:02d}T10:00:00Z",
|
|
"author": {"id": 7 + number % 2, "username": f"writer{number % 2}", "url": f"https://forum.test/u/{number % 2}"},
|
|
"url": f"https://forum.test/posts/{1000 + number}",
|
|
"comments": [
|
|
{
|
|
"id": 5000 + number * 10 + index,
|
|
"body": f"Comment {index} on post {number}",
|
|
"created_at": f"2026-10-{number:02d}T11:0{index}:00Z",
|
|
"user": {"id": 20 + index, "username": f"replier{index}"},
|
|
}
|
|
for index in range(2)
|
|
],
|
|
}
|
|
|
|
|
|
def json_community() -> dict:
|
|
posts = [json_post(number) for number in range(1, 9)]
|
|
return {
|
|
"https://forum.test/": '<html lang="en"><head><title>Forum Test</title>'
|
|
'<meta property="og:description" content="A friendly forum about code."></head>'
|
|
"<body><p>Join 1,234 members discussing code every single day on this community site.</p></body></html>",
|
|
"https://forum.test/api/posts": {"items": posts, "total": 8},
|
|
"https://forum.test/api/posts?page=2": {"items": [json_post(9), json_post(10)], "total": 8},
|
|
"https://forum.test/about": "<html><body><p>About: 2,500 members strong.</p></body></html>",
|
|
}
|
|
|
|
|
|
RSS = """<?xml version="1.0"?>
|
|
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>
|
|
<title>Blog Test</title><description>A blog</description>
|
|
{items}
|
|
</channel></rss>"""
|
|
|
|
RSS_ITEM = """<item><title>Entry {n} on testing</title><link>https://blog.test/p/{n}</link>
|
|
<guid>https://blog.test/p/{n}</guid><pubDate>Tue, {n:02d} Oct 2026 10:00:00 GMT</pubDate>
|
|
<dc:creator>Alice</dc:creator><description><p>Body of entry {n} with enough text.</p></description></item>"""
|
|
|
|
|
|
def detail_page(kind: str, n: int, comments: int = 2) -> str:
|
|
rows = "".join(
|
|
f'<div class="comment" id="c{n}-{index}"><span class="author">replier{index}</span>'
|
|
f'<time datetime="2026-10-{n:02d}T12:0{index}:00Z">t</time>'
|
|
f'<div class="comment-body">Comment {index} on {kind} {n} with words</div>'
|
|
+ (
|
|
f'<div class="comment" id="c{n}-{index}-r"><span class="author">deep{index}</span>'
|
|
f'<div class="comment-body">Nested reply to comment {index} on {n}</div></div>'
|
|
if index == 0
|
|
else ""
|
|
)
|
|
+ "</div>"
|
|
for index in range(comments)
|
|
)
|
|
body = f"Full body of {kind} {n}. " + "It goes on at length about the topic. " * 6
|
|
return f"<html><body><article>{body}</article><section>{rows}</section></body></html>"
|
|
|
|
|
|
def rss_community() -> dict:
|
|
items = "".join(RSS_ITEM.format(n=n) for n in range(1, 7))
|
|
pages = {f"https://blog.test/p/{n}": detail_page("entry", n) for n in range(1, 7)}
|
|
return {
|
|
**pages,
|
|
"https://blog.test/": '<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml">'
|
|
"<title>Blog Test</title></head><body>hi</body></html>",
|
|
"https://blog.test/feed.xml": RSS.format(items=items),
|
|
}
|
|
|
|
|
|
HTML_ITEM = """<article class="post"><h2><a href="/t/{n}">Thread {n} on shells and terminals</a></h2>
|
|
<span class="author">user{n}</span><time datetime="2026-10-{n:02d}T09:00:00Z">date</time>
|
|
<p>Excerpt of thread {n} which is certainly long enough to count as content.</p></article>"""
|
|
|
|
|
|
def html_community() -> dict:
|
|
items = "".join(HTML_ITEM.format(n=n) for n in range(1, 7))
|
|
pages = {f"https://board.test/t/{n}": detail_page("thread", n) for n in range(1, 7)}
|
|
return {
|
|
**pages,
|
|
"https://board.test/": f"<html><head><title>Board</title></head><body><main>{items}</main></body></html>",
|
|
}
|
|
|
|
|
|
async def no_sleep(_seconds):
|
|
return None
|
|
|
|
|
|
class FakePage:
|
|
def __init__(self, fetcher):
|
|
self.fetcher = fetcher
|
|
self.filled: dict[str, str] = {}
|
|
self.url = ""
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return None
|
|
|
|
async def goto(self, url, **kwargs):
|
|
self.url = url
|
|
status, self.html = await self.fetcher.render_html(url)
|
|
return type("Resp", (), {"status": status})()
|
|
|
|
async def wait_for_timeout(self, _ms):
|
|
return None
|
|
|
|
async def content(self):
|
|
return self.html
|
|
|
|
async def fill(self, selector, value):
|
|
self.filled[selector] = value
|
|
|
|
async def click(self, selector):
|
|
self.fetcher.posts.append(("click", dict(self.filled)))
|
|
|
|
|
|
SPA_SHELL = "<html><head><title>Spa</title></head><body><div id=app></div></body></html>"
|
|
|
|
|
|
def spa_community() -> dict:
|
|
items = "".join(HTML_ITEM.format(n=n) for n in range(1, 7))
|
|
return {
|
|
"https://spa.test/": SPA_SHELL,
|
|
"render:https://spa.test/": f"<html><body><main>{items}</main></body></html>",
|
|
}
|
|
|
|
|
|
WALL = (
|
|
'<html><body><form action="/login" method="post"><input type="hidden" name="csrf" value="t1">'
|
|
'<input type="text" name="user"><input type="password" name="pass"></form></body></html>'
|
|
)
|
|
CLUB_HOME = (
|
|
'<html><head><title>Club</title><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head>'
|
|
"<body><p>Members only area with plenty of text on the page for the club.</p></body></html>"
|
|
)
|
|
|
|
|
|
def club_community(state: dict) -> dict:
|
|
items = "".join(RSS_ITEM.format(n=n).replace("blog.test", "club.test") for n in range(1, 7))
|
|
feed = RSS.format(items=items)
|
|
|
|
def login(data):
|
|
state["in"] = data.get("user") == "bob" and data.get("pass") == "pw" and data.get("csrf") == "t1"
|
|
return (200, "welcome" if state["in"] else WALL)
|
|
|
|
return {
|
|
"https://club.test/": lambda: CLUB_HOME if state.get("in") else WALL,
|
|
"https://club.test/login": WALL,
|
|
"https://club.test/feed.xml": lambda: feed if state.get("in") else (401, "login"),
|
|
"post:https://club.test/login": login,
|
|
}
|